Coverage for node / src / stigmem_node / routes / recall / ranking.py: 85%

64 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-18 05:34 +0000

1"""Recall scoring and packing stages.""" 

2 

3from __future__ import annotations 

4 

5from ...auth import Identity 

6from ...fact_visibility import caller_read_scope 

7from ...models.facts import FactRecord 

8from ...models.recall import RecallWeights, ScoreBreakdown, ScoredFact 

9from ...plugins import get_registry 

10from .common import _estimate_tokens, _recency_score 

11 

12 

13def _filter_visible_gardens( 

14 facts: dict[str, FactRecord], identity: Identity 

15) -> dict[str, FactRecord]: 

16 """Drop facts whose garden the caller cannot see (audit M3, defense-in-depth). 

17 

18 Applied to the candidate set of EVERY recall path (live and time-travel/as_of) 

19 so the per-record garden check is a redundant backstop rather than the sole 

20 gate. Uses the projected ``record.garden_id`` (consistent with the ranker) and 

21 a single batched membership lookup via the shared read scope. No-op only when 

22 the boundary is not enforced (fail-closed: it stays on once gardens exist). 

23 """ 

24 scope = caller_read_scope(identity) 

25 return {k: v for k, v in facts.items() if scope.garden_allows(v.garden_id)} 

26 

27 

28def _score_candidates( 

29 all_facts: dict[str, FactRecord], 

30 lex_scores: dict[str, float], 

31 sem_scores: dict[str, float], 

32 graph_hops: dict[str, int], 

33 weights: RecallWeights, 

34 identity: Identity, 

35 depth: int, 

36) -> list[ScoredFact]: 

37 """Compute composite score for each candidate fact.""" 

38 w = weights 

39 total_weight = w.lexical + w.semantic + w.graph + w.recency 

40 if total_weight <= 0: 

41 total_weight = 1.0 

42 

43 results: list[ScoredFact] = [] 

44 

45 # Garden ACL: resolve the caller's read scope ONCE, then filter in-memory per 

46 # fact via the shared predicate (batched, audit M3 secure-path). Redundant 

47 # backstop — the candidate set is pre-filtered by _filter_visible_gardens — 

48 # but it keeps the ranker self-defending. 

49 scope = caller_read_scope(identity) 

50 

51 for fact_id, record in all_facts.items(): 

52 # Skip quarantined / fully-redacted 

53 if record.quarantine_status == "pending": 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true

54 continue 

55 

56 # Salience signal: contradiction-resolution status 

57 contradiction_factor = 0.1 if record.contradicted else 1.0 

58 

59 # Salience signal: garden tier (quarantine garden = 0, normal = 1) 

60 garden_factor = 1.0 

61 if scope.enforce_gardens and record.garden_id is not None: 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true

62 if not scope.garden_allows(record.garden_id): 

63 continue # hidden by ACL 

64 # Penalise quarantine-tagged gardens (§17) 

65 garden_factor = 0.5 

66 

67 # Source-trust rank contribution is plugin-owned. Default installs keep 

68 # this salience signal at 0 and can receive deltas from recall_rank. 

69 st_score = 0.0 

70 

71 # Lexical signal 

72 lex = lex_scores.get(fact_id, 0.0) 

73 

74 # Semantic signal 

75 sem = sem_scores.get(fact_id, 0.0) 

76 

77 # Graph signal: inverse of hop distance (direct=0 → no graph bonus, 1 hop → 0.5, etc.) 

78 hops = graph_hops.get(fact_id) 

79 if hops is not None and hops > 0: 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true

80 graph_s = 1.0 / (1.0 + hops) 

81 else: 

82 graph_s = 0.0 if hops is None else 1.0 

83 

84 # Salience signal: recency 

85 recency_s = _recency_score(record.timestamp) 

86 

87 # Salience signal: decay proxy (confidence itself encodes decay) 

88 decay_factor = max(0.0, record.confidence) 

89 

90 # Weighted sum (normalised by sum of non-zero weights) 

91 raw_total = ( 

92 w.lexical * lex 

93 + w.semantic * sem 

94 + w.graph * graph_s 

95 + w.recency * recency_s 

96 ) / total_weight 

97 

98 # Apply multiplicative adjustments 

99 final_score = raw_total * decay_factor * contradiction_factor * garden_factor 

100 

101 breakdown = ScoreBreakdown( 

102 lexical=round(lex, 4), 

103 semantic=round(sem, 4), 

104 graph=round(graph_s, 4), 

105 source_trust=round(st_score, 4), 

106 recency=round(recency_s, 4), 

107 weighted_total=round(final_score, 6), 

108 ) 

109 

110 results.append( 

111 ScoredFact( 

112 fact=record, 

113 score=round(final_score, 6), 

114 score_breakdown=breakdown, 

115 hop_distance=hops if hops is not None else 0, 

116 token_estimate=_estimate_tokens(record), 

117 ) 

118 ) 

119 

120 source_deltas = get_registry().fire_score_delta( 

121 "recall_rank", 

122 results, 

123 identity=identity, 

124 weights=weights, 

125 depth=depth, 

126 ) 

127 if source_deltas: 

128 adjusted: list[ScoredFact] = [] 

129 for scored in results: 

130 delta = source_deltas.get(scored.fact.id, 0.0) 

131 if delta == 0.0: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true

132 adjusted.append(scored) 

133 continue 

134 next_score = round(max(0.0, scored.score + delta), 6) 

135 adjusted.append( 

136 scored.model_copy( 

137 update={ 

138 "score": next_score, 

139 "score_breakdown": scored.score_breakdown.model_copy( 

140 update={"weighted_total": next_score} 

141 ), 

142 } 

143 ) 

144 ) 

145 results = adjusted 

146 

147 return results 

148 

149 

150# --------------------------------------------------------------------------- 

151# Token-budget greedy packing 

152# --------------------------------------------------------------------------- 

153 

154 

155def _greedy_pack( 

156 candidates: list[ScoredFact], 

157 token_budget: int, 

158) -> tuple[list[ScoredFact], int, bool]: 

159 """Sort by score desc; include facts until budget exhausted. 

160 

161 Returns (packed_facts, tokens_used, truncated). 

162 """ 

163 candidates.sort(key=lambda c: c.score, reverse=True) 

164 packed: list[ScoredFact] = [] 

165 tokens_used = 0 

166 truncated = False 

167 

168 for candidate in candidates: 

169 if tokens_used + candidate.token_estimate > token_budget: 

170 truncated = True 

171 continue # skip this fact (too large), keep trying smaller ones 

172 packed.append(candidate) 

173 tokens_used += candidate.token_estimate 

174 

175 # Secondary sort within packed: preserve score order 

176 packed.sort(key=lambda c: c.score, reverse=True) 

177 return packed, tokens_used, truncated