Files
2026-08-11 10:40:10 +01:00

517 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}