384 lines
14 KiB
Go
384 lines
14 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|