Code sync, security fixes
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user