Code sync, security fixes

This commit is contained in:
Andy Dixon
2026-08-11 10:40:10 +01:00
parent 4e00deedf8
commit 53dbc28383
141 changed files with 36194 additions and 0 deletions
+780
View File
@@ -0,0 +1,780 @@
package relationships
import (
"context"
"fmt"
"log/slog"
"math"
"sort"
"strings"
"time"
"github.com/andydixon/relata/internal/analysis"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
"github.com/andydixon/relata/internal/jobs"
"github.com/andydixon/relata/internal/observability"
)
// Discoverer finds candidate relationships for a newly-processed item.
//
// It never compares an item with every other item. Candidates come from three
// bounded, indexed sources — vector neighbours, items sharing an entity, and
// items in a temporal window — and the union is capped before any scoring or
// model call happens.
type Discoverer struct {
items domain.ItemRepository
chunks domain.ChunkRepository
entities domain.EntityRepository
aliases domain.AliasRepository
claims domain.ClaimRepository
rels domain.RelationshipRepository
vectors domain.VectorStore
embedder domain.Embedder
analysis *analysis.Service
cfg *config.Config
}
// NewDiscoverer builds the relationship discoverer.
func NewDiscoverer(items domain.ItemRepository, chunks domain.ChunkRepository,
ents domain.EntityRepository, aliases domain.AliasRepository, claims domain.ClaimRepository,
rels domain.RelationshipRepository, vectors domain.VectorStore,
embedder domain.Embedder, an *analysis.Service, cfg *config.Config) *Discoverer {
return &Discoverer{
items: items, chunks: chunks, entities: ents, aliases: aliases, claims: claims,
rels: rels, vectors: vectors, embedder: embedder, analysis: an, cfg: cfg,
}
}
// Stats summarise one discovery run.
type Stats struct {
Candidates int
Scored int
Stored int
ModelCalls int
Discarded int
}
// candidate accumulates evidence about one potential relationship.
type candidate struct {
itemID string
item *domain.Item
signals []domain.Signal
srcChunk []string
tgtChunk []string
shared []string
bestSim float64
srcText string
tgtText string
}
// DiscoverForItem finds, scores and stores relationships for one item.
func (d *Discoverer) DiscoverForItem(ctx context.Context, item *domain.Item, pool *jobs.Pool) (Stats, error) {
log := observability.LoggerFrom(ctx)
var stats Stats
chunks, err := d.chunks.ListByItem(ctx, item.WorkspaceID, item.ID)
if err != nil {
return stats, err
}
if len(chunks) == 0 {
return stats, nil
}
cands := map[string]*candidate{}
get := func(itemID string) *candidate {
c := cands[itemID]
if c == nil {
c = &candidate{itemID: itemID}
cands[itemID] = c
}
return c
}
// --- Source 1: semantically similar chunks -----------------------------
//
// Vector search is filtered to the workspace and excludes the item itself,
// so the result set is already bounded and relevant.
if err := d.gatherBySimilarity(ctx, item, chunks, get); err != nil {
log.Warn("similarity candidate search failed", "error", err.Error())
}
// --- Source 2: items sharing an entity ---------------------------------
itemEntities, err := d.entities.ListByItem(ctx, item.WorkspaceID, item.ID)
if err != nil {
return stats, err
}
if err := d.gatherByEntities(ctx, item, itemEntities, get); err != nil {
log.Warn("shared-entity candidate search failed", "error", err.Error())
}
// --- Source 3: items making claims about the same subject --------------
itemClaims, err := d.claims.ListByItem(ctx, item.WorkspaceID, item.ID)
if err != nil {
return stats, err
}
if err := d.gatherByClaims(ctx, item, itemClaims, get); err != nil {
log.Warn("claim candidate search failed", "error", err.Error())
}
if len(cands) == 0 {
return stats, nil
}
// Cap the candidate set before any expensive work.
ordered := rankCandidates(cands, d.cfg.Rel.MaxCandidates)
stats.Candidates = len(ordered)
// Load candidate items in one query.
ids2 := make([]string, 0, len(ordered))
for _, c := range ordered {
ids2 = append(ids2, c.itemID)
}
others, err := d.items.GetMany(ctx, item.WorkspaceID, ids2)
if err != nil {
return stats, err
}
byID := make(map[string]*domain.Item, len(others))
for _, it := range others {
byID[it.ID] = it
}
// Confirmed aliases, fetched once for the whole run and indexed both ways.
// Querying per candidate would be O(candidates × entities) round trips, and
// the stored direction is arbitrary, so a one-way lookup would miss half
// the pairs.
aliasPartners := d.confirmedAliasIndex(ctx, item.WorkspaceID)
// Entity corpus frequency, so a name appearing everywhere counts for less.
entIDs := make([]string, 0, len(itemEntities))
for _, e := range itemEntities {
entIDs = append(entIDs, e.ID)
}
freq := map[string]int{}
if f, ok := d.entities.(interface {
CorpusFrequency(context.Context, string, []string) (map[string]int, error)
}); ok {
if got, err := f.CorpusFrequency(ctx, item.WorkspaceID, entIDs); err == nil {
freq = got
}
}
for _, c := range ordered {
other := byID[c.itemID]
if other == nil || other.Deleted {
continue
}
c.item = other
d.addEntitySignals(ctx, item, other, itemEntities, freq, aliasPartners, c)
d.addTemporalSignal(item, other, c)
d.addMetadataSignals(item, other, c)
d.addClaimSignals(ctx, item, other, itemClaims, c)
if item.ContentHash == other.ContentHash {
c.signals = append(c.signals, ContentHashSignal(item.ContentHash))
}
}
// Score without the model first, so model calls go only to candidates that
// already have real evidence behind them.
sort.SliceStable(ordered, func(i, j int) bool {
return Score(ordered[i].signals) > Score(ordered[j].signals)
})
for _, c := range ordered {
if c.item == nil {
continue
}
base := Score(c.signals)
if base < d.cfg.Rel.MinScore {
stats.Discarded++
continue
}
var opinion *analysis.RelationshipOpinion
if d.analysis.Enabled() && stats.ModelCalls < d.cfg.Rel.MaxModelCalls {
op, err := d.classify(ctx, item, c, pool)
if err == nil && op != nil {
stats.ModelCalls++
opinion = op
// A model that says "not related" must not simultaneously have
// its type suggestion recorded as supporting evidence. Only one
// of these two signals is ever added.
if op.Related {
c.signals = append(c.signals,
ModelClassificationSignal(op.Type, op.Confidence, op.Rationale, op.Model))
} else {
c.signals = append(c.signals, domain.Signal{
Type: domain.SigModelClassify,
Value: "judged unrelated",
Strength: 0,
Weight: Weights[domain.SigModelClassify],
Detail: "A language model judged these items unrelated despite the other signals. " +
"This is a model opinion, not a verified fact.",
})
}
}
}
stats.Scored++
if err := d.store(ctx, item, c, opinion); err != nil {
log.Warn("could not store a relationship", "target", c.itemID, "error", err.Error())
continue
}
stats.Stored++
}
return stats, nil
}
// gatherBySimilarity finds items with semantically similar chunks.
func (d *Discoverer) gatherBySimilarity(ctx context.Context, item *domain.Item,
chunks []*domain.Chunk, get func(string) *candidate) error {
// Embedding every chunk again would be wasteful; a bounded sample of the
// item's chunks is enough to find its neighbourhood.
sample := chunks
const maxProbe = 8
if len(sample) > maxProbe {
step := len(sample) / maxProbe
picked := make([]*domain.Chunk, 0, maxProbe)
for i := 0; i < len(sample) && len(picked) < maxProbe; i += step {
picked = append(picked, sample[i])
}
sample = picked
}
texts := make([]string, len(sample))
for i, c := range sample {
texts[i] = c.Text
}
vecs, err := d.embedder.Embed(ctx, texts)
if err != nil {
return err
}
perChunk := d.cfg.Rel.MaxNeighboursPerChunk
if perChunk <= 0 {
perChunk = 10
}
for i, v := range vecs {
matches, err := d.vectors.Search(ctx, v, perChunk, domain.VectorFilter{
WorkspaceID: item.WorkspaceID,
ExcludeItemIDs: []string{item.ID},
EmbeddingModel: d.embedder.ModelID(),
})
if err != nil {
return err
}
for _, m := range matches {
if m.ItemID == "" || m.ItemID == item.ID {
continue
}
c := get(m.ItemID)
if m.Score > c.bestSim {
c.bestSim = m.Score
c.srcText = sample[i].Text
}
c.srcChunk = appendUnique(c.srcChunk, sample[i].ID, 5)
c.tgtChunk = appendUnique(c.tgtChunk, m.ChunkID, 5)
}
}
// The similarity signal itself is built in store, from the best match
// recorded above, so that it reflects the strongest evidence found.
return nil
}
// gatherByEntities finds items referencing the same entities.
func (d *Discoverer) gatherByEntities(ctx context.Context, item *domain.Item,
ents []*domain.Entity, get func(string) *candidate) error {
if len(ents) == 0 {
return nil
}
entIDs := make([]string, 0, len(ents))
for _, e := range ents {
// Concepts are shared too widely to be useful as a candidate source;
// they still contribute a signal once a candidate exists.
if e.Type == domain.EntityConcept {
continue
}
entIDs = append(entIDs, e.ID)
}
if len(entIDs) == 0 {
return nil
}
itemIDs, err := d.entities.ItemsSharingEntities(ctx, item.WorkspaceID, entIDs, item.ID,
d.cfg.Rel.MaxCandidates*2)
if err != nil {
return err
}
for _, id := range itemIDs {
get(id)
}
return nil
}
// gatherByClaims finds items making claims about the same subjects.
func (d *Discoverer) gatherByClaims(ctx context.Context, item *domain.Item,
claims []*domain.Claim, get func(string) *candidate) error {
if len(claims) == 0 {
return nil
}
subjects := make([]string, 0, len(claims))
seen := map[string]bool{}
for _, c := range claims {
if c.Subject != "" && !seen[c.Subject] {
seen[c.Subject] = true
subjects = append(subjects, c.Subject)
}
}
if len(subjects) == 0 {
return nil
}
others, err := d.claims.FindBySubjects(ctx, item.WorkspaceID, subjects, item.ID,
d.cfg.Rel.MaxCandidates*3)
if err != nil {
return err
}
for _, oc := range others {
get(oc.ItemID)
}
return nil
}
// addEntitySignals records exactly which entities two items share.
func (d *Discoverer) addEntitySignals(ctx context.Context, item, other *domain.Item,
itemEntities []*domain.Entity, freq map[string]int,
aliasPartners map[string][]aliasLink, c *candidate) {
otherEnts, err := d.entities.ListByItem(ctx, other.WorkspaceID, other.ID)
if err != nil {
return
}
otherByKey := make(map[string]*domain.Entity, len(otherEnts))
for _, e := range otherEnts {
otherByKey[e.NormalisedKey] = e
}
concepts := []string{}
added := 0
for _, e := range itemEntities {
if _, ok := otherByKey[e.NormalisedKey]; !ok {
continue
}
if e.Type == domain.EntityConcept || e.Type == domain.EntitySubject {
concepts = append(concepts, e.CanonicalName)
continue
}
if added >= 6 {
continue
}
added++
c.shared = append(c.shared, e.CanonicalName)
c.signals = append(c.signals, SharedEntitySignal(e.CanonicalName, e.Type, freq[e.ID]))
}
// Entities linked by a CONFIRMED alias count too, at a lower weight than an
// exact match: the identification came from a person, but the two records
// are still separate entities. Unconfirmed candidate aliases are ignored
// entirely — treating a guess as evidence is exactly what the alias
// mechanism exists to avoid.
addAliasSignals(itemEntities, otherEnts, otherByKey, aliasPartners, c)
if len(concepts) > 0 {
// Overlap fraction, so two documents sharing three of four concepts
// score higher than two sharing three of forty.
denom := float64(maxInt(len(itemEntities), len(otherEnts)))
overlap := float64(len(concepts))
if denom > 0 {
overlap /= denom
}
c.signals = append(c.signals, SharedConceptSignal(concepts, overlap))
}
}
// aliasLink is one confirmed "these are the same thing" link between entities.
type aliasLink struct {
otherID string
similarity float64
}
// maxConfirmedAliases bounds the index. Confirmed aliases require human action,
// so a workspace will not have many.
const maxConfirmedAliases = 500
// confirmedAliasIndex maps each entity to the entities a person has confirmed
// it is the same as, in both directions.
//
// Only CONFIRMED aliases appear. A candidate alias is a proposal, and treating
// a proposal as evidence is exactly what the alias mechanism exists to avoid.
func (d *Discoverer) confirmedAliasIndex(ctx context.Context, workspaceID string) map[string][]aliasLink {
if d.aliases == nil {
return nil
}
links, total, err := d.aliases.List(ctx, workspaceID, domain.AliasConfirmed,
domain.Page{Limit: maxConfirmedAliases})
if err != nil || len(links) == 0 {
return nil
}
if total > int64(len(links)) {
// Past the cap, confirmed aliases stop contributing their signal. That
// weakens discovery rather than breaking it, but silently dropping
// human-confirmed information is not something to do quietly.
slog.Warn("confirmed alias index truncated; aliases beyond the cap will not contribute a signal",
"workspace_id", workspaceID, "confirmed", total, "indexed", len(links),
"cap", maxConfirmedAliases)
}
index := make(map[string][]aliasLink, len(links)*2)
for _, l := range links {
if l.EntityID == "" || l.CandidateEntityID == "" {
continue
}
index[l.EntityID] = append(index[l.EntityID],
aliasLink{otherID: l.CandidateEntityID, similarity: l.Similarity})
index[l.CandidateEntityID] = append(index[l.CandidateEntityID],
aliasLink{otherID: l.EntityID, similarity: l.Similarity})
}
return index
}
// addAliasSignals records matches made through a human-confirmed alias.
//
// These are weighted below an exact match: the identification came from a
// person, but the two records remain separate entities.
func addAliasSignals(itemEntities, otherEnts []*domain.Entity,
otherByKey map[string]*domain.Entity, aliasPartners map[string][]aliasLink, c *candidate) {
if len(aliasPartners) == 0 || len(itemEntities) == 0 || len(otherEnts) == 0 {
return
}
otherByID := make(map[string]*domain.Entity, len(otherEnts))
for _, e := range otherEnts {
otherByID[e.ID] = e
}
added := 0
for _, e := range itemEntities {
if added >= 3 {
return
}
// An exact match is already counted as a shared entity.
if _, exact := otherByKey[e.NormalisedKey]; exact {
continue
}
for _, link := range aliasPartners[e.ID] {
match, ok := otherByID[link.otherID]
if !ok {
continue
}
c.signals = append(c.signals,
AliasEntitySignal(e.CanonicalName, match.CanonicalName, link.similarity))
c.shared = append(c.shared, e.CanonicalName)
added++
break
}
}
}
// addTemporalSignal records how close in time two items are.
func (d *Discoverer) addTemporalSignal(item, other *domain.Item, c *candidate) {
if item.CreatedAt.IsZero() || other.CreatedAt.IsZero() {
return
}
gap := item.CreatedAt.Sub(other.CreatedAt)
sig := TemporalProximitySignal(gap, d.cfg.Rel.TemporalWindow)
if sig.Strength > 0 {
c.signals = append(c.signals, sig)
}
}
// addMetadataSignals records matching user-supplied metadata.
func (d *Discoverer) addMetadataSignals(item, other *domain.Item, c *candidate) {
added := 0
for k, v := range item.Metadata {
if added >= 3 {
return
}
if ov, ok := other.Metadata[k]; ok && ov == v && v != "" {
c.signals = append(c.signals, SharedMetadataSignal(k, v))
added++
}
}
}
// numericTolerance is the relative difference below which two figures are
// treated as the same value rather than a disagreement.
const numericTolerance = 0.001
// addClaimSignals compares the two items' claims for agreement and conflict.
//
// This is what makes "which sources disagree about the cost?" answerable
// without a language model: two claims about the same subject carrying
// materially different figures are a contradiction, and it is recorded with
// both quotations attached.
func (d *Discoverer) addClaimSignals(ctx context.Context, item, other *domain.Item,
itemClaims []*domain.Claim, c *candidate) {
if len(itemClaims) == 0 {
return
}
otherClaims, err := d.claims.ListByItem(ctx, other.WorkspaceID, other.ID)
if err != nil || len(otherClaims) == 0 {
return
}
bySubject := map[string][]*domain.Claim{}
for _, oc := range otherClaims {
if oc.Subject != "" {
bySubject[oc.Subject] = append(bySubject[oc.Subject], oc)
}
}
supports, conflicts := 0, 0
for _, ic := range itemClaims {
if ic.Subject == "" {
continue
}
for _, oc := range bySubject[ic.Subject] {
agree, disagree, detail := compareClaims(ic, oc)
switch {
case disagree && conflicts < 3:
conflicts++
c.signals = append(c.signals, ClaimContradictionSignal(ic.Subject, detail, 1))
c.srcText = ic.SourceRef.Quote
c.tgtText = oc.SourceRef.Quote
case agree && supports < 3:
supports++
c.signals = append(c.signals, ClaimSupportSignal(ic.Subject, detail, 0.8))
if c.srcText == "" {
c.srcText = ic.SourceRef.Quote
c.tgtText = oc.SourceRef.Quote
}
}
}
}
}
// compareClaims decides whether two claims about the same subject agree.
func compareClaims(a, b *domain.Claim) (agree, disagree bool, detail string) {
// Figures about the same subject.
if len(a.Numbers) > 0 && len(b.Numbers) > 0 {
an, bn := a.Numbers[0], b.Numbers[0]
if an == 0 && bn == 0 {
return true, false, "Both sources state the same figure for " + a.Subject + "."
}
denom := math.Max(math.Abs(an), math.Abs(bn))
if denom > 0 && math.Abs(an-bn)/denom < numericTolerance {
return true, false, fmt.Sprintf("Both sources give %s as %s.", a.Subject, formatNumber(an))
}
return false, true, fmt.Sprintf(
"The sources give different figures for %s: %s and %s. Relata does not decide which is correct.",
a.Subject, formatNumber(an), formatNumber(bn))
}
// Dates about the same subject.
if len(a.Dates) > 0 && len(b.Dates) > 0 {
if a.Dates[0] == b.Dates[0] {
return true, false, fmt.Sprintf("Both sources give %s as %s.", a.Subject, a.Dates[0])
}
return false, true, fmt.Sprintf(
"The sources give different dates for %s: %s and %s. Relata does not decide which is correct.",
a.Subject, a.Dates[0], b.Dates[0])
}
// Opposite polarity on the same subject.
if a.Polarity != 0 && b.Polarity != 0 && a.Polarity != b.Polarity {
return false, true, fmt.Sprintf("One source affirms and the other denies a statement about %s.", a.Subject)
}
return false, false, ""
}
func formatNumber(v float64) string {
if v == math.Trunc(v) && math.Abs(v) < 1e15 {
return fmt.Sprintf("%.0f", v)
}
return fmt.Sprintf("%g", v)
}
// classify asks the model for its opinion on one candidate.
func (d *Discoverer) classify(ctx context.Context, item *domain.Item, c *candidate, pool *jobs.Pool) (*analysis.RelationshipOpinion, error) {
srcText := c.srcText
if srcText == "" {
srcText = excerpt(item.ExtractedText, 1200)
}
tgtText := c.tgtText
if tgtText == "" && c.item != nil {
tgtText = excerpt(c.item.ExtractedText, 1200)
}
if strings.TrimSpace(srcText) == "" || strings.TrimSpace(tgtText) == "" {
return nil, nil
}
release, err := pool.AcquireAI(ctx)
if err != nil {
return nil, err
}
defer release()
return d.analysis.ClassifyRelationship(ctx, srcText, tgtText, c.shared)
}
// store writes a scored relationship, or skips it when too weak.
func (d *Discoverer) store(ctx context.Context, item *domain.Item, c *candidate, opinion *analysis.RelationshipOpinion) error {
// Add the similarity signal now that the best match is known.
signals := c.signals
if c.bestSim > 0 {
signals = append(signals, SimilaritySignal(c.bestSim))
}
signals = Normalise(signals)
score := Score(signals)
if score < d.cfg.Rel.StoreScore {
return nil
}
// A discovered relationship has not been reviewed, so it cannot reach the
// score reserved for a human decision.
if score > MaxUnreviewedScore {
score = MaxUnreviewedScore
}
var opType *domain.RelationshipType
if opinion != nil && opinion.Related {
opType = &opinion.Type
}
relType := InferType(signals, item.CreatedAt, c.item.CreatedAt, opType)
// A near-duplicate is flagged as such but never merged: two items may be
// near-identical and still be genuinely separate records.
if c.bestSim >= d.cfg.Rel.NearDuplicateSimilarity && item.ContentHash != c.item.ContentHash {
signals = append(signals, domain.Signal{
Type: domain.SigSemanticSimilarity,
Value: fmt.Sprintf("%.2f — likely near-duplicate", c.bestSim),
Strength: 1,
Weight: Weights[domain.SigSemanticSimilarity],
Detail: "These items are nearly identical in meaning. Relata has NOT merged them; review them if one supersedes the other.",
})
score = Score(Normalise(signals))
if score > MaxUnreviewedScore {
score = MaxUnreviewedScore
}
}
// Non-directional types are stored in a canonical order so that A~B and
// B~A are one relationship, not two.
src, tgt := item.ID, c.item.ID
srcTime, tgtTime := item.CreatedAt, c.item.CreatedAt
if !relType.Directed() && src > tgt {
src, tgt = tgt, src
}
// A chronological type must be stated in the direction the dates support.
if relType == domain.RelOccurredBefore && srcTime.After(tgtTime) {
relType = domain.RelOccurredAfter
}
evidence := domain.Evidence{
Signals: signals,
SourceChunkIDs: c.srcChunk,
TargetChunkIDs: c.tgtChunk,
SharedEntities: c.shared,
SourceExcerpt: excerpt(c.srcText, 400),
TargetExcerpt: excerpt(c.tgtText, 400),
}
if opinion != nil {
evidence.ModelRationale = opinion.Rationale
evidence.ModelVersion = opinion.Model
}
provenance := domain.ProvDeterministic
if opinion != nil {
// The model influenced the type, so the relationship is a model
// inference and must be labelled as one.
provenance = domain.ProvModelInferred
}
now := time.Now().UTC()
rel := &domain.Relationship{
ID: ids.New(),
WorkspaceID: item.WorkspaceID,
SourceKind: domain.NodeItem,
SourceID: src,
TargetKind: domain.NodeItem,
TargetID: tgt,
Type: relType,
Provenance: provenance,
Score: score,
ScoreVersion: ScoreVersion,
Evidence: evidence,
OriginalType: relType,
OriginalProvenance: provenance,
ReviewStatus: domain.ReviewPending,
// The dedupe key excludes the type and ignores endpoint order, so that
// one connection between two items is one relationship however it was
// found. Without the ordering, discovery run from each end would store
// the same link twice — once as A→B and once as B→A.
DedupeKey: PairKey(item.WorkspaceID, src, tgt),
CreatedAt: now,
UpdatedAt: now,
}
_, err := d.rels.UpsertByDedupeKey(ctx, rel)
return err
}
// rankCandidates caps the candidate set, keeping those with the most evidence.
func rankCandidates(cands map[string]*candidate, limit int) []*candidate {
out := make([]*candidate, 0, len(cands))
for _, c := range cands {
out = append(out, c)
}
sort.SliceStable(out, func(i, j int) bool {
if len(out[i].shared) != len(out[j].shared) {
return len(out[i].shared) > len(out[j].shared)
}
if out[i].bestSim != out[j].bestSim {
return out[i].bestSim > out[j].bestSim
}
return out[i].itemID < out[j].itemID
})
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out
}
func appendUnique(list []string, v string, max int) []string {
if v == "" || len(list) >= max {
return list
}
for _, e := range list {
if e == v {
return list
}
}
return append(list, v)
}
func excerpt(s string, n int) string {
s = strings.TrimSpace(collapseSpace(s))
if len(s) <= n {
return s
}
for n > 0 && n < len(s) && s[n]&0xC0 == 0x80 {
n--
}
return s[:n] + "…"
}
func collapseSpace(s string) string { return strings.Join(strings.Fields(s), " ") }
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
+349
View File
@@ -0,0 +1,349 @@
package relationships
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/andydixon/relata/internal/audit"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
)
// Reviewer records human decisions about relationships.
//
// Every decision is additive. Rejecting a relationship does not delete it, and
// editing its type does not erase what was originally proposed: the original
// type and provenance are preserved on the record and every action appends to
// an immutable history. A user must be able to ask "what did the system think,
// and who overruled it?" months later.
type Reviewer struct {
rels domain.RelationshipRepository
items domain.ItemRepository
audit *audit.Recorder
}
// NewReviewer builds the review service.
func NewReviewer(rels domain.RelationshipRepository, items domain.ItemRepository, rec *audit.Recorder) *Reviewer {
return &Reviewer{rels: rels, items: items, audit: rec}
}
const maxNoteLength = 2000
// Confirm records that a person agrees with a relationship.
//
// The confirmation is added as a signal, so the score rises through the same
// arithmetic as everything else rather than being overwritten with a magic
// number, and the provenance becomes user_confirmed.
func (r *Reviewer) Confirm(ctx context.Context, p *domain.Principal, id, note string) (*domain.Relationship, error) {
rel, err := r.load(ctx, p, id)
if err != nil {
return nil, err
}
signals := stripUserSignals(rel.Evidence.Signals)
signals = append(signals, UserConfirmedSignal(actorName(p)))
rel.Evidence.Signals = Normalise(signals)
rel.Score = Score(rel.Evidence.Signals)
rel.ScoreVersion = ScoreVersion
rel.Provenance = domain.ProvUserConfirmed
rel.UpdatedAt = time.Now().UTC()
if err := r.rels.Update(ctx, rel); err != nil {
return nil, err
}
ev := domain.ReviewEvent{
At: time.Now().UTC(), Action: "confirm",
ActorID: p.UserID, ActorName: p.Username,
Note: cleanNote(note),
FromValue: string(rel.OriginalProvenance),
ToValue: string(domain.ProvUserConfirmed),
}
out, err := r.rels.AppendReview(ctx, p.WorkspaceID, id, ev, domain.ReviewConfirmed, rel.Type, domain.ProvUserConfirmed)
if err != nil {
return nil, err
}
r.audit.Record(ctx, p, domain.AuditRelConfirmed, "relationship", id, "success", map[string]string{
"type": string(out.Type), "score": fmt.Sprintf("%.3f", out.Score),
})
return out, nil
}
// Reject records that a person disagrees with a relationship.
//
// The relationship and its evidence are kept. Its score falls to zero through
// the rejection signal, and it stops influencing search expansion — but the
// record of what was proposed, and who rejected it, survives.
func (r *Reviewer) Reject(ctx context.Context, p *domain.Principal, id, note string) (*domain.Relationship, error) {
rel, err := r.load(ctx, p, id)
if err != nil {
return nil, err
}
signals := stripUserSignals(rel.Evidence.Signals)
signals = append(signals, UserRejectedSignal(actorName(p)))
rel.Evidence.Signals = Normalise(signals)
rel.Score = Score(rel.Evidence.Signals)
rel.ScoreVersion = ScoreVersion
rel.Provenance = domain.ProvUserRejected
rel.UpdatedAt = time.Now().UTC()
if err := r.rels.Update(ctx, rel); err != nil {
return nil, err
}
ev := domain.ReviewEvent{
At: time.Now().UTC(), Action: "reject",
ActorID: p.UserID, ActorName: p.Username,
Note: cleanNote(note),
FromValue: string(rel.OriginalProvenance),
ToValue: string(domain.ProvUserRejected),
}
out, err := r.rels.AppendReview(ctx, p.WorkspaceID, id, ev, domain.ReviewRejected, rel.Type, domain.ProvUserRejected)
if err != nil {
return nil, err
}
r.audit.Record(ctx, p, domain.AuditRelRejected, "relationship", id, "success", map[string]string{
"type": string(out.Type), "original_type": string(out.OriginalType),
})
return out, nil
}
// ChangeType records that a person corrected the relationship's type.
func (r *Reviewer) ChangeType(ctx context.Context, p *domain.Principal, id string, newType domain.RelationshipType, note string) (*domain.Relationship, error) {
if !newType.Valid() {
return nil, fmt.Errorf("%w: %q is not a recognised relationship type", domain.ErrInvalidInput, newType)
}
rel, err := r.load(ctx, p, id)
if err != nil {
return nil, err
}
if rel.Type == newType {
return rel, nil
}
previous := rel.Type
// A person correcting the type is asserting it, so the link becomes
// user-confirmed with a confirmation signal behind its score.
signals := stripUserSignals(rel.Evidence.Signals)
signals = append(signals, UserConfirmedSignal(actorName(p)))
rel.Evidence.Signals = Normalise(signals)
rel.Score = Score(rel.Evidence.Signals)
rel.ScoreVersion = ScoreVersion
rel.Type = newType
rel.Provenance = domain.ProvUserConfirmed
rel.UpdatedAt = time.Now().UTC()
if err := r.rels.Update(ctx, rel); err != nil {
return nil, err
}
ev := domain.ReviewEvent{
At: time.Now().UTC(), Action: "change_type",
ActorID: p.UserID, ActorName: p.Username,
Note: cleanNote(note),
FromValue: string(previous),
ToValue: string(newType),
}
out, err := r.rels.AppendReview(ctx, p.WorkspaceID, id, ev, domain.ReviewEdited, newType, domain.ProvUserConfirmed)
if err != nil {
return nil, err
}
r.audit.Record(ctx, p, domain.AuditRelEdited, "relationship", id, "success", map[string]string{
"from": string(previous), "to": string(newType),
})
return out, nil
}
// CreateManual records a relationship a person asserted directly.
//
// It is stored with explicit provenance and confirmed review status: a human
// statement is not a proposal awaiting review.
func (r *Reviewer) CreateManual(ctx context.Context, p *domain.Principal,
sourceID, targetID string, relType domain.RelationshipType, note string) (*domain.Relationship, error) {
if p == nil || !p.Role.CanWrite() {
return nil, domain.ErrForbidden
}
if !relType.Valid() {
return nil, fmt.Errorf("%w: %q is not a recognised relationship type", domain.ErrInvalidInput, relType)
}
if sourceID == targetID {
return nil, fmt.Errorf("%w: an item cannot be related to itself", domain.ErrInvalidInput)
}
if !ids.Valid(sourceID) || !ids.Valid(targetID) {
return nil, fmt.Errorf("%w: item identifiers must be UUIDs", domain.ErrInvalidInput)
}
// Both endpoints must exist IN THIS WORKSPACE. This is the check that stops
// a relationship being created across the workspace boundary.
src, err := r.items.Get(ctx, p.WorkspaceID, sourceID)
if err != nil {
return nil, fmt.Errorf("source item: %w", err)
}
tgt, err := r.items.Get(ctx, p.WorkspaceID, targetID)
if err != nil {
return nil, fmt.Errorf("target item: %w", err)
}
if src.Deleted || tgt.Deleted {
return nil, fmt.Errorf("%w: one of the items has been deleted", domain.ErrNotFound)
}
s, t := sourceID, targetID
if !relType.Directed() && s > t {
s, t = t, s
}
now := time.Now().UTC()
signals := Normalise([]domain.Signal{UserConfirmedSignal(actorName(p))})
rel := &domain.Relationship{
ID: ids.New(),
WorkspaceID: p.WorkspaceID,
SourceKind: domain.NodeItem,
SourceID: s,
TargetKind: domain.NodeItem,
TargetID: t,
Type: relType,
Provenance: domain.ProvExplicit,
Score: Score(signals),
ScoreVersion: ScoreVersion,
Evidence: domain.Evidence{
Signals: signals,
SourceExcerpt: excerpt(src.ExtractedText, 300),
TargetExcerpt: excerpt(tgt.ExtractedText, 300),
},
OriginalType: relType,
OriginalProvenance: domain.ProvExplicit,
ReviewStatus: domain.ReviewConfirmed,
ReviewHistory: []domain.ReviewEvent{{
At: now, Action: "create", ActorID: p.UserID, ActorName: p.Username,
Note: cleanNote(note), ToValue: string(relType),
}},
DedupeKey: PairKey(p.WorkspaceID, s, t),
CreatedAt: now,
UpdatedAt: now,
}
// Upsert rather than insert: if analysis already proposed a link between
// this pair, the person's assertion replaces the proposal instead of
// creating a second edge.
out, err := r.upsertManual(ctx, rel)
if err != nil {
return nil, err
}
r.audit.Record(ctx, p, domain.AuditRelCreated, "relationship", out.ID, "success", map[string]string{
"type": string(relType), "source": s, "target": t,
})
return out, nil
}
// upsertManual writes a user-asserted relationship over any existing proposal.
//
// The existing link is looked up by dedupe key rather than by scanning the
// node's relationships: a well-connected item has more than one page of them, so
// a paged scan would miss the match and then collide with the unique index.
//
// A person asserting a relationship is the authority on it, so this deliberately
// overwrites type, provenance and review status — unlike UpsertByDedupeKey,
// which protects a human decision from a later analysis run.
func (r *Reviewer) upsertManual(ctx context.Context, rel *domain.Relationship) (*domain.Relationship, error) {
existing, err := r.rels.FindByDedupeKey(ctx, rel.WorkspaceID, rel.DedupeKey)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, err
}
if existing != nil {
return r.applyManual(ctx, existing, rel)
}
if err := r.rels.Create(ctx, rel); err != nil {
// Another request created the same link between the lookup and this
// insert. The unique index caught it; fold this assertion into the
// record that won rather than failing the user's action.
if errors.Is(err, domain.ErrDuplicate) {
winner, findErr := r.rels.FindByDedupeKey(ctx, rel.WorkspaceID, rel.DedupeKey)
if findErr == nil && winner != nil {
return r.applyManual(ctx, winner, rel)
}
}
return nil, err
}
return rel, nil
}
// applyManual overlays a user assertion onto an existing relationship record,
// keeping the original identity and appending to the review history rather than
// replacing it.
func (r *Reviewer) applyManual(ctx context.Context, existing, rel *domain.Relationship) (*domain.Relationship, error) {
existing.Type = rel.Type
existing.Provenance = domain.ProvExplicit
existing.Score = rel.Score
existing.ScoreVersion = rel.ScoreVersion
existing.Evidence.Signals = rel.Evidence.Signals
existing.ReviewStatus = domain.ReviewConfirmed
existing.ReviewHistory = append(existing.ReviewHistory, rel.ReviewHistory...)
existing.UpdatedAt = rel.UpdatedAt
if err := r.rels.Update(ctx, existing); err != nil {
return nil, err
}
return existing, nil
}
// load fetches a relationship, enforcing the workspace boundary.
func (r *Reviewer) load(ctx context.Context, p *domain.Principal, id string) (*domain.Relationship, error) {
if p == nil || p.WorkspaceID == "" {
return nil, domain.ErrWorkspaceMiss
}
if !p.Role.CanWrite() {
return nil, domain.ErrForbidden
}
if !ids.Valid(id) {
return nil, fmt.Errorf("%w: relationship id must be a UUID", domain.ErrInvalidInput)
}
return r.rels.Get(ctx, p.WorkspaceID, id)
}
// stripUserSignals removes previous human-decision signals so that changing a
// decision replaces it rather than stacking a confirmation on a rejection.
func stripUserSignals(signals []domain.Signal) []domain.Signal {
out := signals[:0:0]
for _, s := range signals {
if s.Type == domain.SigUserConfirmed || s.Type == domain.SigUserRejected {
continue
}
out = append(out, s)
}
return out
}
func actorName(p *domain.Principal) string {
if p == nil {
return "unknown"
}
if p.DisplayName != "" {
return p.DisplayName
}
return p.Username
}
// cleanNote bounds and sanitises a user-supplied note. It is stored as plain
// text and escaped on output; this only keeps it a sane size and free of
// control characters.
func cleanNote(s string) string {
s = strings.Map(func(r rune) rune {
if r < 0x20 && r != '\n' || r == 0x7f {
return -1
}
return r
}, strings.TrimSpace(s))
if len(s) > maxNoteLength {
s = s[:maxNoteLength]
}
return s
}
+516
View File
@@ -0,0 +1,516 @@
// Package relationships discovers, scores and reviews connections between
// stored items.
//
// The scoring model is deliberately not a black box. A relationship's strength
// is the sum of weighted contributions from named signals, each of which is
// stored alongside the score so the interface can show the arithmetic. A
// language model contributes exactly one of those signals; it is never asked
// for a percentage and its opinion can never be the whole score.
//
// The resulting number is a HEURISTIC RELATIONSHIP STRENGTH. It is not a
// calibrated probability and must not be presented as one.
package relationships
import (
"fmt"
"math"
"sort"
"time"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
)
// ScoreVersion identifies the scoring model. Every stored relationship records
// the version that produced its score, so a weight change is detectable and the
// affected relationships can be recomputed rather than silently reinterpreted.
const ScoreVersion = "relationship-score-v1"
// Weights are the per-signal contributions of the current scoring model.
//
// The values are chosen so that:
// - no single soft signal can reach the storage threshold alone;
// - semantic similarity, on its own, tops out well below a confirmed link,
// because similarity is not evidence of a factual relationship;
// - human decisions dominate everything else.
var Weights = map[domain.SignalType]float64{
domain.SigSemanticSimilarity: 0.30,
domain.SigSharedEntity: 0.25,
domain.SigAliasEntity: 0.10,
domain.SigSharedConcept: 0.12,
domain.SigTemporalProximity: 0.10,
domain.SigSharedMetadata: 0.08,
domain.SigClaimSupport: 0.22,
domain.SigClaimContradiction: 0.22,
domain.SigModelClassify: 0.15,
domain.SigContentHashMatch: 0.45,
domain.SigUserConfirmed: 1.00,
domain.SigUserRejected: -1.00,
}
// WeightOrder lists the signal types in a fixed, meaningful order: strongest
// evidence first, human decisions last.
//
// Map iteration order is random, so anything that displays the weights needs
// this to render the same way twice.
func WeightOrder() []domain.SignalType {
return []domain.SignalType{
domain.SigContentHashMatch,
domain.SigSemanticSimilarity,
domain.SigSharedEntity,
domain.SigClaimSupport,
domain.SigClaimContradiction,
domain.SigModelClassify,
domain.SigSharedConcept,
domain.SigAliasEntity,
domain.SigTemporalProximity,
domain.SigSharedMetadata,
domain.SigUserConfirmed,
domain.SigUserRejected,
}
}
// PairKey builds the deduplication key identifying one connection between two
// items.
//
// Endpoint order is normalised, so a link discovered while processing A and the
// same link rediscovered while processing B collide on one record. The type is
// deliberately excluded: a reprocess that reclassifies a relationship should
// update it, not add a second edge between the same pair.
func PairKey(workspaceID, a, b string) string {
if a > b {
a, b = b, a
}
return ids.Deterministic(workspaceID, a, b)
}
// maxSignalsOfType caps how many instances of one signal type contribute, so
// twelve shared entities do not simply out-vote every other consideration.
const maxSignalsOfType = 3
// diminishing is the factor applied to the second and subsequent signals of
// the same type.
const diminishing = 0.5
// MaxUnreviewedScore is the ceiling for a relationship no person has reviewed.
//
// Only a human decision can produce a 1.00. Without this cap, a candidate with
// enough accumulated signals would reach the same strength as one someone had
// actually checked, and the interface would present them identically — exactly
// the conflation between "much evidence" and "confirmed" the design avoids.
const MaxUnreviewedScore = 0.95
// Score computes the relationship strength from a signal set.
//
// Repeated signals of the same type contribute with diminishing returns and are
// capped, so evidence breadth matters more than evidence repetition. The result
// is clamped to 0..1: a rejection drives it to zero rather than negative.
func Score(signals []domain.Signal) float64 {
byType := map[domain.SignalType][]domain.Signal{}
for _, s := range signals {
byType[s.Type] = append(byType[s.Type], s)
}
total := 0.0
for _, group := range byType {
// Strongest first, so the full-weight slot goes to the best evidence.
sort.SliceStable(group, func(i, j int) bool {
return math.Abs(group[i].Contribution()) > math.Abs(group[j].Contribution())
})
factor := 1.0
for i, s := range group {
if i >= maxSignalsOfType {
break
}
total += s.Contribution() * factor
factor *= diminishing
}
}
return clamp01(total)
}
// Normalise fills in the configured weight for each signal and clamps its
// strength, so a caller cannot accidentally construct an over-weighted signal.
func Normalise(signals []domain.Signal) []domain.Signal {
out := make([]domain.Signal, 0, len(signals))
for _, s := range signals {
s.Weight = Weights[s.Type]
if s.Type == domain.SigUserRejected {
s.Strength = clampAbs(s.Strength)
} else {
s.Strength = clamp01(s.Strength)
}
out = append(out, s)
}
return out
}
func clamp01(v float64) float64 {
if math.IsNaN(v) || v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func clampAbs(v float64) float64 {
if math.IsNaN(v) {
return 0
}
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// ---------------------------------------------------------------------------
// Signal builders
//
// Each builder turns a raw observation into a named signal with a normalised
// strength and a human-readable value. The value is what the UI shows as the
// evidence; the strength is what the arithmetic uses.
// ---------------------------------------------------------------------------
// SimilaritySignal converts a cosine similarity into a signal.
//
// Cosine similarity between unrelated documents from the same corpus is
// routinely 0.5–0.7, so the strength curve starts above that floor: without
// rebasing, every pair of documents in a workspace would look related.
func SimilaritySignal(cosine float64) domain.Signal {
const floor = 0.55 // below this, similarity carries no information
strength := 0.0
if cosine > floor {
strength = (cosine - floor) / (1 - floor)
}
return domain.Signal{
Type: domain.SigSemanticSimilarity,
Value: fmt.Sprintf("%.2f cosine", cosine),
Strength: clamp01(strength),
Weight: Weights[domain.SigSemanticSimilarity],
Detail: "How alike the wording and subject matter are. Similar wording is not evidence that two items describe the same thing.",
}
}
// SharedEntitySignal reports an exact entity match between two items.
//
// Strength reflects how discriminating the entity is: a subject or concept
// shared by many documents says less than a specific organisation or person.
func SharedEntitySignal(name string, t domain.EntityType, corpusFrequency int) domain.Signal {
strength := 1.0
switch t {
case domain.EntityConcept, domain.EntitySubject:
strength = 0.5
case domain.EntityDate:
strength = 0.6
}
// A name appearing in most documents is not evidence of a connection.
if corpusFrequency > 1 {
strength *= 1 / (1 + math.Log(float64(corpusFrequency)))
}
return domain.Signal{
Type: domain.SigSharedEntity,
Value: name,
Strength: clamp01(strength),
Weight: Weights[domain.SigSharedEntity],
Detail: fmt.Sprintf("Both items refer to this %s.", t),
}
}
// AliasEntitySignal reports a match made through an alias a person confirmed.
//
// Only confirmed aliases produce this signal; an unconfirmed candidate is a
// proposal and contributes nothing. It is still weighted below an exact name
// match, because the two entities remain separate records linked by a human
// judgement rather than by identical names.
func AliasEntitySignal(nameA, nameB string, similarity float64) domain.Signal {
return domain.Signal{
Type: domain.SigAliasEntity,
Value: fmt.Sprintf("%s ≈ %s", nameA, nameB),
Strength: clamp01(similarity),
Weight: Weights[domain.SigAliasEntity],
Detail: "A person confirmed that these two names refer to the same thing. " +
"They remain separate entity records.",
}
}
// SharedConceptSignal reports overlapping concept vocabulary.
func SharedConceptSignal(concepts []string, overlap float64) domain.Signal {
label := "shared concepts"
if len(concepts) > 0 {
label = joinLimit(concepts, 3)
}
return domain.Signal{
Type: domain.SigSharedConcept,
Value: label,
Strength: clamp01(overlap),
Weight: Weights[domain.SigSharedConcept],
Detail: "Both items discuss these concepts.",
}
}
// TemporalProximitySignal reports how close two items are in time.
//
// Proximity in time is context, never causation. The detail text says so,
// because a user reading a score needs to know what it does not mean.
func TemporalProximitySignal(gap time.Duration, window time.Duration) domain.Signal {
if gap < 0 {
gap = -gap
}
strength := 0.0
if window > 0 && gap < window {
// Linear decay across the window.
strength = 1 - float64(gap)/float64(window)
}
return domain.Signal{
Type: domain.SigTemporalProximity,
Value: humaniseDuration(gap),
Strength: clamp01(strength),
Weight: Weights[domain.SigTemporalProximity],
Detail: "The items were created close together in time. This is context, not evidence that one caused or followed from the other.",
}
}
// SharedMetadataSignal reports matching user-supplied metadata.
func SharedMetadataSignal(key, value string) domain.Signal {
return domain.Signal{
Type: domain.SigSharedMetadata,
Value: key + "=" + value,
Strength: 1,
Weight: Weights[domain.SigSharedMetadata],
Detail: "Both items carry this metadata value.",
}
}
// ClaimSupportSignal reports two claims agreeing about the same subject.
func ClaimSupportSignal(subject, detail string, strength float64) domain.Signal {
return domain.Signal{
Type: domain.SigClaimSupport,
Value: subject,
Strength: clamp01(strength),
Weight: Weights[domain.SigClaimSupport],
Detail: detail,
}
}
// ClaimContradictionSignal reports two claims disagreeing about the same
// subject. Contradictions are surfaced, never reconciled away.
func ClaimContradictionSignal(subject, detail string, strength float64) domain.Signal {
return domain.Signal{
Type: domain.SigClaimContradiction,
Value: subject,
Strength: clamp01(strength),
Weight: Weights[domain.SigClaimContradiction],
Detail: detail,
}
}
// ModelClassificationSignal records a language model's opinion as ONE signal.
//
// The model's self-reported confidence scales the signal's strength, but the
// weight caps how much any model opinion can move the score. A model cannot,
// on its own, push a relationship past the storage threshold.
func ModelClassificationSignal(relType domain.RelationshipType, confidence float64, rationale, model string) domain.Signal {
return domain.Signal{
Type: domain.SigModelClassify,
Value: string(relType),
Strength: clamp01(confidence),
Weight: Weights[domain.SigModelClassify],
Detail: fmt.Sprintf("A language model (%s) suggested this relationship type. This is a model opinion, not a verified fact. %s",
model, rationale),
}
}
// ContentHashSignal reports byte-identical content.
func ContentHashSignal(hash string) domain.Signal {
return domain.Signal{
Type: domain.SigContentHashMatch,
Value: shortHash(hash),
Strength: 1,
Weight: Weights[domain.SigContentHashMatch],
Detail: "The two items have identical content.",
}
}
// UserConfirmedSignal records a human confirmation.
func UserConfirmedSignal(actor string) domain.Signal {
return domain.Signal{
Type: domain.SigUserConfirmed,
Value: "confirmed by " + actor,
Strength: 1,
Weight: Weights[domain.SigUserConfirmed],
Detail: "A person reviewed and confirmed this relationship.",
}
}
// UserRejectedSignal records a human rejection. Its negative weight drives the
// score to zero without deleting the evidence that produced the proposal.
func UserRejectedSignal(actor string) domain.Signal {
return domain.Signal{
Type: domain.SigUserRejected,
Value: "rejected by " + actor,
Strength: 1,
Weight: Weights[domain.SigUserRejected],
Detail: "A person reviewed this relationship and rejected it. The original proposal and its evidence are retained.",
}
}
// ---------------------------------------------------------------------------
// Type inference
// ---------------------------------------------------------------------------
// InferType chooses a relationship type from the evidence.
//
// Deterministic evidence outranks a model opinion: an observed contradiction
// between claims is a stronger basis for calling something `contradicts` than
// a model saying so. When nothing more specific applies, the honest answer is
// `similar_to` — not a guess at a causal or updating relationship.
func InferType(signals []domain.Signal, sourceTime, targetTime time.Time, modelOpinion *domain.RelationshipType) domain.RelationshipType {
has := func(t domain.SignalType) bool {
for _, s := range signals {
if s.Type == t && s.Strength > 0 {
return true
}
}
return false
}
switch {
case has(domain.SigContentHashMatch):
return domain.RelDerivedFrom
case has(domain.SigClaimContradiction):
return domain.RelContradicts
case has(domain.SigClaimSupport):
return domain.RelSupports
}
// A model opinion is accepted only for types it is well placed to judge,
// and never for a claim about identity or supersession, which need
// stronger evidence than a language model's impression.
if modelOpinion != nil && modelOpinion.Valid() {
switch *modelOpinion {
case domain.RelSameEntityAs, domain.RelSupersedes, domain.RelDerivedFrom:
// Not accepted on a model's word alone.
default:
return *modelOpinion
}
}
// Chronology alone justifies only a chronological statement — never
// causation, and never "updates".
if has(domain.SigSharedEntity) && !sourceTime.IsZero() && !targetTime.IsZero() {
if sourceTime.Before(targetTime) {
return domain.RelOccurredBefore
}
if targetTime.Before(sourceTime) {
return domain.RelOccurredAfter
}
}
if has(domain.SigSharedEntity) {
return domain.RelMentions
}
return domain.RelSimilarTo
}
// ---------------------------------------------------------------------------
// Explanation
// ---------------------------------------------------------------------------
// Explain renders the score's arithmetic as ordered lines of plain English,
// used by both the HTML interface and the API.
func Explain(r *domain.Relationship) []string {
if r == nil {
return nil
}
sigs := append([]domain.Signal(nil), r.Evidence.Signals...)
sort.SliceStable(sigs, func(i, j int) bool {
return math.Abs(sigs[i].Contribution()) > math.Abs(sigs[j].Contribution())
})
out := make([]string, 0, len(sigs)+2)
for _, s := range sigs {
out = append(out, fmt.Sprintf("%s: %s (strength %.2f × weight %.2f = %+.2f)",
labelFor(s.Type), s.Value, s.Strength, s.Weight, s.Contribution()))
}
out = append(out, fmt.Sprintf("Relationship strength %.2f under %s. "+
"This is a heuristic strength from the signals above, not a calibrated probability.",
r.Score, r.ScoreVersion))
if r.Provenance == domain.ProvModelInferred {
out = append(out, "Provenance: proposed by analysis and not yet reviewed by a person.")
}
return out
}
func labelFor(t domain.SignalType) string {
switch t {
case domain.SigSemanticSimilarity:
return "Semantic similarity"
case domain.SigSharedEntity:
return "Shared entity"
case domain.SigAliasEntity:
return "Possible alias match"
case domain.SigSharedConcept:
return "Shared concepts"
case domain.SigTemporalProximity:
return "Temporal proximity"
case domain.SigSharedMetadata:
return "Shared metadata"
case domain.SigClaimSupport:
return "Supporting claims"
case domain.SigClaimContradiction:
return "Conflicting claims"
case domain.SigModelClassify:
return "Model classification"
case domain.SigContentHashMatch:
return "Identical content"
case domain.SigUserConfirmed:
return "Confirmed by a person"
case domain.SigUserRejected:
return "Rejected by a person"
}
return string(t)
}
func humaniseDuration(d time.Duration) string {
switch {
case d < time.Minute:
return "under a minute"
case d < time.Hour:
return fmt.Sprintf("%d minutes", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%d hours", int(d.Hours()))
case d < 60*24*time.Hour:
return fmt.Sprintf("%d days", int(d.Hours()/24))
default:
return fmt.Sprintf("%d months", int(d.Hours()/24/30))
}
}
func joinLimit(items []string, n int) string {
if len(items) <= n {
return joinComma(items)
}
return fmt.Sprintf("%s and %d more", joinComma(items[:n]), len(items)-n)
}
func joinComma(items []string) string {
out := ""
for i, s := range items {
if i > 0 {
out += ", "
}
out += s
}
return out
}
func shortHash(h string) string {
if len(h) > 12 {
return h[:12]
}
return h
}
+383
View File
@@ -0,0 +1,383 @@
package relationships
import (
"math"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/andydixon/relata/internal/domain"
)
func TestScoreIsSumOfWeightedContributions(t *testing.T) {
sigs := []domain.Signal{
{Type: domain.SigSemanticSimilarity, Strength: 1, Weight: 0.30},
{Type: domain.SigSharedEntity, Strength: 1, Weight: 0.25},
}
got := Score(sigs)
if diff := got - 0.55; diff > 1e-9 || diff < -1e-9 {
t.Fatalf("score = %v, want the sum of contributions 0.55", got)
}
}
func TestScoreIsClampedToUnitRange(t *testing.T) {
// Enough independent strong evidence to exceed 1 before clamping.
many := []domain.Signal{
{Type: domain.SigContentHashMatch, Strength: 1, Weight: 0.45},
{Type: domain.SigSemanticSimilarity, Strength: 1, Weight: 0.30},
{Type: domain.SigSharedEntity, Strength: 1, Weight: 0.25},
{Type: domain.SigClaimSupport, Strength: 1, Weight: 0.22},
{Type: domain.SigSharedConcept, Strength: 1, Weight: 0.12},
}
if got := Score(many); got != 1 {
t.Fatalf("score = %v, want it clamped to 1", got)
}
if got := Score([]domain.Signal{{Type: domain.SigUserRejected, Strength: 1, Weight: -1}}); got != 0 {
t.Fatalf("rejected relationship scored %v, want 0", got)
}
}
// Evidence breadth should count for more than repetition of one kind of
// evidence, or a document mentioning one company twelve times would outrank a
// document that genuinely relates on several independent grounds.
func TestRepeatedSignalsHaveDiminishingReturns(t *testing.T) {
repeated := make([]domain.Signal, 0, 6)
for i := 0; i < 6; i++ {
repeated = append(repeated, domain.Signal{Type: domain.SigSharedEntity, Strength: 1, Weight: 0.25})
}
repeatedScore := Score(repeated)
varied := []domain.Signal{
{Type: domain.SigSharedEntity, Strength: 1, Weight: 0.25},
{Type: domain.SigSemanticSimilarity, Strength: 1, Weight: 0.30},
{Type: domain.SigTemporalProximity, Strength: 1, Weight: 0.10},
}
if Score(varied) <= repeatedScore {
t.Fatalf("six repeats (%v) outranked three independent signals (%v)", repeatedScore, Score(varied))
}
// And repetition must be capped rather than accumulating without limit.
if repeatedScore >= 0.25*6 {
t.Fatalf("repeated signals accumulated without diminishing: %v", repeatedScore)
}
}
// The central design claim: similarity alone must not look like a strong,
// evidenced relationship.
func TestSemanticSimilarityAloneCannotProduceAStrongScore(t *testing.T) {
perfect := Score([]domain.Signal{SimilaritySignal(0.99)})
if perfect >= 0.35 {
t.Fatalf("near-identical wording alone scored %v; similarity is not evidence of a factual relationship", perfect)
}
}
func TestSimilaritySignalIgnoresBaselineSimilarity(t *testing.T) {
// Unrelated documents in one corpus routinely sit around 0.5–0.6 cosine.
if s := SimilaritySignal(0.55); s.Strength != 0 {
t.Fatalf("baseline similarity produced strength %v, want 0", s.Strength)
}
if s := SimilaritySignal(0.40); s.Strength != 0 {
t.Fatalf("below-baseline similarity produced strength %v, want 0", s.Strength)
}
high := SimilaritySignal(0.95)
low := SimilaritySignal(0.70)
if high.Strength <= low.Strength {
t.Fatal("similarity strength is not monotonic")
}
}
func TestSharedEntityStrengthFallsWithCorpusFrequency(t *testing.T) {
rare := SharedEntitySignal("Example Ltd", domain.EntityOrganisation, 1)
common := SharedEntitySignal("Example Ltd", domain.EntityOrganisation, 200)
if common.Strength >= rare.Strength {
t.Fatalf("a ubiquitous entity (%v) counts as much as a rare one (%v)", common.Strength, rare.Strength)
}
concept := SharedEntitySignal("logistics", domain.EntityConcept, 1)
if concept.Strength >= rare.Strength {
t.Fatal("a shared concept should be weaker evidence than a shared organisation")
}
}
// A model must never be able to manufacture a relationship by itself.
func TestModelOpinionAloneCannotReachTheStorageThreshold(t *testing.T) {
const typicalStoreThreshold = 0.25
confident := ModelClassificationSignal(domain.RelContradicts, 1.0, "very sure", "some-model")
if got := Score([]domain.Signal{confident}); got >= typicalStoreThreshold {
t.Fatalf("a maximally confident model opinion alone scored %v, at or above the storage threshold %v",
got, typicalStoreThreshold)
}
}
func TestModelSignalIsLabelledAsAnOpinion(t *testing.T) {
s := ModelClassificationSignal(domain.RelSupports, 0.8, "they agree on the date", "llama")
if !strings.Contains(s.Detail, "model opinion") || !strings.Contains(s.Detail, "not a verified fact") {
t.Fatalf("model signal is not labelled as an opinion: %q", s.Detail)
}
}
func TestTemporalSignalDisclaimsCausation(t *testing.T) {
s := TemporalProximitySignal(48*time.Hour, 7*24*time.Hour)
if !strings.Contains(strings.ToLower(s.Detail), "not evidence") {
t.Fatalf("temporal signal does not disclaim causation: %q", s.Detail)
}
near := TemporalProximitySignal(time.Hour, 7*24*time.Hour)
far := TemporalProximitySignal(6*24*time.Hour, 7*24*time.Hour)
if near.Strength <= far.Strength {
t.Fatal("temporal strength does not decay with distance")
}
outside := TemporalProximitySignal(30*24*time.Hour, 7*24*time.Hour)
if outside.Strength != 0 {
t.Fatalf("a gap beyond the window scored %v, want 0", outside.Strength)
}
}
func TestUserDecisionsDominate(t *testing.T) {
base := []domain.Signal{
{Type: domain.SigSemanticSimilarity, Strength: 1, Weight: 0.30},
{Type: domain.SigSharedEntity, Strength: 1, Weight: 0.25},
}
confirmed := Score(append(append([]domain.Signal{}, base...), UserConfirmedSignal("alice")))
if confirmed != 1 {
t.Fatalf("confirmed relationship scored %v, want 1", confirmed)
}
rejected := Score(append(append([]domain.Signal{}, base...), UserRejectedSignal("alice")))
if rejected != 0 {
t.Fatalf("rejected relationship scored %v, want 0", rejected)
}
}
func TestNormaliseAppliesConfiguredWeights(t *testing.T) {
// A caller cannot smuggle in an inflated weight.
got := Normalise([]domain.Signal{{Type: domain.SigSemanticSimilarity, Strength: 5, Weight: 99}})
if got[0].Weight != Weights[domain.SigSemanticSimilarity] {
t.Fatalf("weight %v was not replaced with the configured value", got[0].Weight)
}
if got[0].Strength != 1 {
t.Fatalf("strength %v was not clamped to 1", got[0].Strength)
}
}
// --- Type inference --------------------------------------------------------
func TestObservedContradictionOutranksModelOpinion(t *testing.T) {
opinion := domain.RelSupports
sigs := []domain.Signal{
ClaimContradictionSignal("project cost", "£42,000 versus £51,500", 1),
ModelClassificationSignal(domain.RelSupports, 0.9, "", "m"),
}
if got := InferType(sigs, time.Time{}, time.Time{}, &opinion); got != domain.RelContradicts {
t.Fatalf("inferred %q; an observed contradiction must outrank a model's opinion", got)
}
}
func TestModelCannotAssertIdentityOrSupersession(t *testing.T) {
sigs := []domain.Signal{SimilaritySignal(0.9)}
for _, forbidden := range []domain.RelationshipType{domain.RelSameEntityAs, domain.RelSupersedes, domain.RelDerivedFrom} {
op := forbidden
if got := InferType(sigs, time.Time{}, time.Time{}, &op); got == forbidden {
t.Fatalf("a model's word alone established %q", forbidden)
}
}
}
func TestChronologyAloneYieldsOnlyAChronologicalType(t *testing.T) {
earlier := time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)
later := time.Date(2025, 3, 10, 0, 0, 0, 0, time.UTC)
sigs := []domain.Signal{SharedEntitySignal("Example Ltd", domain.EntityOrganisation, 1)}
got := InferType(sigs, earlier, later, nil)
if got != domain.RelOccurredBefore {
t.Fatalf("inferred %q, want occurred_before", got)
}
// Never an unproven causal or updating claim.
for _, forbidden := range []domain.RelationshipType{domain.RelPossiblyExplains, domain.RelUpdates, domain.RelSupersedes} {
if got == forbidden {
t.Fatalf("chronology alone produced %q", forbidden)
}
}
}
func TestSimilarityOnlyYieldsSimilarTo(t *testing.T) {
if got := InferType([]domain.Signal{SimilaritySignal(0.97)}, time.Time{}, time.Time{}, nil); got != domain.RelSimilarTo {
t.Fatalf("inferred %q from similarity alone, want similar_to", got)
}
}
func TestIdenticalContentYieldsDerivedFrom(t *testing.T) {
sigs := []domain.Signal{ContentHashSignal("abc123def456789")}
if got := InferType(sigs, time.Time{}, time.Time{}, nil); got != domain.RelDerivedFrom {
t.Fatalf("inferred %q for identical content, want derived_from", got)
}
}
// --- Explanation -----------------------------------------------------------
func TestExplainShowsTheArithmeticAndTheCaveat(t *testing.T) {
r := &domain.Relationship{
Score: 0.55,
ScoreVersion: ScoreVersion,
Provenance: domain.ProvModelInferred,
Evidence: domain.Evidence{Signals: []domain.Signal{
SimilaritySignal(0.9),
SharedEntitySignal("Example Ltd", domain.EntityOrganisation, 1),
}},
}
lines := Explain(r)
joined := strings.Join(lines, "\n")
for _, want := range []string{"Semantic similarity", "Shared entity", "Example Ltd", "strength", "weight"} {
if !strings.Contains(joined, want) {
t.Errorf("explanation is missing %q:\n%s", want, joined)
}
}
if !strings.Contains(joined, "not a calibrated probability") {
t.Error("explanation does not state that the score is heuristic")
}
if !strings.Contains(joined, "not yet reviewed by a person") {
t.Error("an unreviewed model-inferred relationship is not labelled as such")
}
if !strings.Contains(joined, ScoreVersion) {
t.Error("the scoring version is not recorded in the explanation")
}
}
func TestExplainOrdersByContribution(t *testing.T) {
r := &domain.Relationship{
ScoreVersion: ScoreVersion,
Evidence: domain.Evidence{Signals: []domain.Signal{
{Type: domain.SigSharedMetadata, Value: "weak", Strength: 0.1, Weight: 0.08},
{Type: domain.SigContentHashMatch, Value: "strong", Strength: 1, Weight: 0.45},
}},
}
lines := Explain(r)
if !strings.Contains(lines[0], "Identical content") {
t.Fatalf("strongest signal is not listed first:\n%s", strings.Join(lines, "\n"))
}
}
func TestEveryWeightedSignalTypeHasAWeightAndLabel(t *testing.T) {
all := []domain.SignalType{
domain.SigSemanticSimilarity, domain.SigSharedEntity, domain.SigAliasEntity,
domain.SigSharedConcept, domain.SigTemporalProximity, domain.SigSharedMetadata,
domain.SigClaimSupport, domain.SigClaimContradiction, domain.SigModelClassify,
domain.SigUserConfirmed, domain.SigUserRejected, domain.SigContentHashMatch,
}
for _, tp := range all {
if _, ok := Weights[tp]; !ok {
t.Errorf("signal %q has no configured weight", tp)
}
if labelFor(tp) == string(tp) {
t.Errorf("signal %q has no human-readable label", tp)
}
}
}
// --- Deduplication ---------------------------------------------------------
// One connection between two items is one relationship, however it was found.
// Discovery runs once per item, so without an order-independent key the same
// link is stored twice — once as A→B and once as B→A.
func TestPairKeyIsIndependentOfEndpointOrder(t *testing.T) {
const ws = "workspace-1"
a, b := "aaaaaaaa-0000-4000-8000-000000000001", "bbbbbbbb-0000-4000-8000-000000000002"
if PairKey(ws, a, b) != PairKey(ws, b, a) {
t.Fatal("the dedupe key depends on endpoint order, so each link would be stored twice")
}
}
func TestPairKeyDistinguishesDifferentPairsAndWorkspaces(t *testing.T) {
a := "aaaaaaaa-0000-4000-8000-000000000001"
b := "bbbbbbbb-0000-4000-8000-000000000002"
c := "cccccccc-0000-4000-8000-000000000003"
if PairKey("ws1", a, b) == PairKey("ws1", a, c) {
t.Fatal("different pairs collide on one key")
}
// A workspace boundary must be part of the identity.
if PairKey("ws1", a, b) == PairKey("ws2", a, b) {
t.Fatal("the same pair in two workspaces shares a key")
}
}
func TestUnreviewedScoreIsCappedBelowAHumanDecision(t *testing.T) {
// Enough independent evidence to saturate the raw score.
strong := []domain.Signal{
{Type: domain.SigContentHashMatch, Strength: 1, Weight: 0.45},
{Type: domain.SigSemanticSimilarity, Strength: 1, Weight: 0.30},
{Type: domain.SigSharedEntity, Strength: 1, Weight: 0.25},
{Type: domain.SigClaimSupport, Strength: 1, Weight: 0.22},
}
if Score(strong) != 1 {
t.Fatalf("expected the raw score to saturate, got %v", Score(strong))
}
// The ceiling exists so "much evidence" never presents as "confirmed".
if MaxUnreviewedScore >= 1.0 {
t.Fatalf("MaxUnreviewedScore = %v; it must be below 1.00, which is reserved for a human decision",
MaxUnreviewedScore)
}
confirmed := Score(append(append([]domain.Signal{}, strong...), UserConfirmedSignal("alice")))
if confirmed <= MaxUnreviewedScore {
t.Fatal("a confirmed relationship does not outrank the strongest unreviewed one")
}
}
// --- Alias signals ---------------------------------------------------------
func TestAliasSignalIsWeakerThanAnExactEntityMatch(t *testing.T) {
exact := SharedEntitySignal("Example Ltd", domain.EntityOrganisation, 1)
alias := AliasEntitySignal("BBC", "British Broadcasting Corporation", 0.9)
if alias.Contribution() >= exact.Contribution() {
t.Fatalf("an alias match (%v) counts as much as an exact match (%v); "+
"the identification is a human judgement about two separate records",
alias.Contribution(), exact.Contribution())
}
// The signal only fires for a confirmed alias, so its wording must say so
// and must make clear the records are still separate.
if !strings.Contains(alias.Detail, "person confirmed") ||
!strings.Contains(alias.Detail, "separate entity records") {
t.Fatalf("the alias signal does not describe what it means: %q", alias.Detail)
}
}
// excerpt slices UTF-8 evidence text at a computed offset for display.
func FuzzExcerpt(f *testing.F) {
f.Add("The revised delivery date is 14 March 2025.", 20)
f.Add("ééééééé", 3)
f.Add("🌍🌍🌍", 2)
f.Add("", 0)
f.Fuzz(func(t *testing.T, text string, n int) {
if n < 0 || n > 1<<16 || !utf8.ValidString(text) {
return
}
if got := excerpt(text, n); !utf8.ValidString(got) {
t.Fatalf("excerpt(%q, %d) produced invalid UTF-8: %q", text, n, got)
}
})
}
// Score must stay in range whatever signals it is handed, including values a
// caller should never construct.
func FuzzScore(f *testing.F) {
f.Add(1.0, 0.3, 2)
f.Add(-5.0, 99.0, 7)
f.Fuzz(func(t *testing.T, strength, weight float64, count int) {
if count < 0 || count > 64 {
return
}
sigs := make([]domain.Signal, 0, count)
for i := 0; i < count; i++ {
sigs = append(sigs, domain.Signal{
Type: domain.SigSharedEntity, Strength: strength, Weight: weight,
})
}
got := Score(Normalise(sigs))
if math.IsNaN(got) || got < 0 || got > 1 {
t.Fatalf("Score = %v for strength=%v weight=%v count=%d; must stay within 0..1",
got, strength, weight, count)
}
})
}