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