● Zion Boggan
repos/Darwin/cortex/ranking.py
zionboggan.com ↗
183 lines · python
History for this file →
1
"""Darwin Cortex - ranking & reconciliation engine.
2
 
3
Score formula (per CONTRACTS.md §3):
4
    score = w_authority(source) * recency_decay(last_seen) * confidence * relevance
5
 
6
recency_decay = exp(-age_days / HALF_LIFE)   HALF_LIFE default 30d
7
"""
8
from __future__ import annotations
9
 
10
import math
11
from datetime import datetime, timezone
12
from typing import Optional
13
 
14
from models import Conflict, Hit
15
 
16
HALF_LIFE_DAYS: float = 30.0
17
 
18
AUTHORITY_WEIGHTS: dict[str, float] = {
19
    "live-infra": 1.0,
20
    "recent-file": 0.8,
21
    "agent-memory": 0.6,
22
    "older-note": 0.4,
23
}
24
 
25
 
26
def recency_decay(last_seen: Optional[datetime], half_life: float = HALF_LIFE_DAYS) -> float:
27
    """Exponential decay based on age in days. Unknown last_seen → max decay (0.5)."""
28
    if last_seen is None:
29
        return 0.5
30
    now = datetime.now(timezone.utc)
31
    if last_seen.tzinfo is None:
32
        last_seen = last_seen.replace(tzinfo=timezone.utc)
33
    age_days = max(0.0, (now - last_seen).total_seconds() / 86400.0)
34
    return math.exp(-age_days / half_life)
35
 
36
 
37
def authority_weight(tier: str) -> float:
38
    return AUTHORITY_WEIGHTS.get(tier, 0.4)
39
 
40
 
41
def compute_score(hit: Hit, relevance: float = 1.0) -> float:
42
    """Apply the Darwin scoring formula to a Hit."""
43
    w = authority_weight(hit.authority_tier)
44
    d = recency_decay(hit.last_seen)
45
    return w * d * hit.confidence * relevance
46
 
47
 
48
def rank_hits(hits: list[Hit], k: int = 50) -> list[Hit]:
49
    """Score and sort a flat list of hits. Mutates hit.score in place.
50
 
51
    Engines (esp. keyword/FTS) frequently return flat or missing relevance
52
    scores, which collapses the final scores into a single value and makes the
53
    ranking meaningless. To guarantee a meaningful spread we combine the engine's
54
    raw relevance with a per-engine rank-position prior, so the top hit per
55
    engine gets full relevance and lower hits decay smoothly. The authority ×
56
    recency × confidence factors then differentiate across engines/sources.
57
    """
58
    if not hits:
59
        return hits
60
 
61
    by_engine: dict[str, list[Hit]] = {}
62
    for h in hits:
63
        by_engine.setdefault(h.engine, []).append(h)
64
 
65
    for engine_hits in by_engine.values():
66
        engine_hits.sort(key=lambda h: h.score, reverse=True)
67
        n = len(engine_hits)
68
        for rank, h in enumerate(engine_hits):
69
            raw = max(0.0, min(1.0, h.score))
70
            rank_prior = 1.0 - (rank / (n + 1))
71
            relevance = 0.5 * raw + 0.5 * rank_prior
72
            relevance = max(0.05, min(1.0, relevance))
73
            h.score = compute_score(h, relevance)
74
 
75
    hits.sort(key=lambda h: h.score, reverse=True)
76
    return hits[:k]
77
 
78
 
79
def detect_conflicts(hits: list[Hit]) -> list[Conflict]:
80
    """Simple conflict detection: group hits by (engine, snippet-fingerprint-cluster).
81
 
82
    In practice: any pair where the snippets contain contradictory keywords for the
83
    same named entity triggers a conflict. For v1 we implement a lightweight
84
    'same-subject different-assertion' heuristic.
85
 
86
    Two hits share a subject if their titles or paths have significant overlap (Jaccard
87
    on tokens ≥ 0.5). If their top-scored hit disagrees with a lower one (score diff > 0)
88
    we record it.
89
    """
90
    conflicts: list[Conflict] = []
91
    seen_pairs: set[frozenset] = set()
92
 
93
    for i, a in enumerate(hits):
94
        for b in hits[i + 1:]:
95
            pair = frozenset([id(a), id(b)])
96
            if pair in seen_pairs:
97
                continue
98
            seen_pairs.add(pair)
99
 
100
            if not _shared_subject(a, b):
101
                continue
102
 
103
            opposition = _opposing_claim(a, b)
104
            if opposition is None:
105
                continue
106
 
107
            winner, loser = (a, b) if a.score >= b.score else (b, a)
108
            age_winner = _age_days(winner.last_seen)
109
            age_loser = _age_days(loser.last_seen)
110
            reason = (
111
                f"{loser.engine} ({loser.authority_tier}) "
112
                f"stale {age_loser:.0f}d vs {winner.engine} ({winner.authority_tier}) "
113
                f"fresh {age_winner:.0f}d; scores {winner.score:.3f} > {loser.score:.3f}"
114
            )
115
            conflicts.append(Conflict(
116
                claim=opposition,
117
                winner=f"{winner.engine}:{winner.authority_tier}",
118
                losers=[f"{loser.engine}:{loser.authority_tier}"],
119
                reason=reason,
120
            ))
121
    return conflicts
122
 
123
 
124
 
125
def _age_days(dt: Optional[datetime]) -> float:
126
    if dt is None:
127
        return 999.0
128
    now = datetime.now(timezone.utc)
129
    if dt.tzinfo is None:
130
        dt = dt.replace(tzinfo=timezone.utc)
131
    return max(0.0, (now - dt).total_seconds() / 86400.0)
132
 
133
 
134
_OPPOSITION_PAIRS: list[tuple[str, str, str]] = [
135
    ("active", "stopped", "service status"),
136
    ("running", "stopped", "service status"),
137
    ("up", "down", "service status"),
138
    ("online", "offline", "service status"),
139
    ("enabled", "disabled", "service state"),
140
    ("deployed", "removed", "deployment state"),
141
    ("live", "dead", "liveness"),
142
    ("open", "closed", "state"),
143
]
144
 
145
 
146
def _opposing_claim(a: Hit, b: Hit) -> Optional[str]:
147
    """Return a claim label if the two hits contain opposing keywords."""
148
    text_a = (a.snippet + " " + a.title).lower()
149
    text_b = (b.snippet + " " + b.title).lower()
150
    for pos, neg, label in _OPPOSITION_PAIRS:
151
        if pos in text_a and neg in text_b:
152
            return label
153
        if neg in text_a and pos in text_b:
154
            return label
155
    return None
156
 
157
 
158
def _jaccard(s1: str, s2: str) -> float:
159
    t1 = set(s1.lower().split())
160
    t2 = set(s2.lower().split())
161
    if not t1 or not t2:
162
        return 0.0
163
    return len(t1 & t2) / len(t1 | t2)
164
 
165
 
166
def _shared_subject(a: Hit, b: Hit) -> bool:
167
    """Return True if the two hits are about the same subject.
168
 
169
    Strategy:
170
    1. Jaccard on (title + path + first 80 chars of snippet) >= 0.2
171
    2. OR: any significant word (len >= 4) appears in both title+snippet combos
172
    """
173
    combined_a = f"{a.title} {a.path} {a.snippet[:80]}"
174
    combined_b = f"{b.title} {b.path} {b.snippet[:80]}"
175
 
176
    if _jaccard(combined_a, combined_b) >= 0.2:
177
        return True
178
 
179
    _STOPWORDS = {"this", "that", "with", "from", "have", "been", "will", "into",
180
                  "running", "service", "status", "result", "value"}
181
    words_a = {w for w in combined_a.lower().split() if len(w) >= 4 and w not in _STOPWORDS}
182
    words_b = {w for w in combined_b.lower().split() if len(w) >= 4 and w not in _STOPWORDS}
183
    return bool(words_a & words_b)