Confidence decay¶
Architecture
Normative spec
This page documents .ai/specs/core-engine/2026-07-02-confidence-decay.md, Status: Approved (F7 math review complete, 2026-07-03, J. Porter, conditions incorporated in v0.2). It is the mathematical companion to Concepts › Confidence & decay, which covers the idea without the formulas.
Multi-hop confidence decay and score fusion are one of three algorithm families in Telha whose math had to clear a dedicated peer review before any code implementing them was allowed to land (F7). This page is the exact math, as reviewed and as built.
Overview & purpose¶
Graph expansion returns neighbors; ranking needs to express "a fact two hops away through weak relationships matters less than a direct fact," in a way that is reproducible across versions and composes predictably with vector similarity. Without pinned math, scores are irreproducible, fusion with similarity is vibes, and the memory planner's evidence budgeting (score-per-token packing) inherits undefined inputs.
The design is multiplicative per-hop decay with per-edge-type weights, a floor that prunes negligible paths outright, and multiplicative (geometric-mean) fusion with vector similarity under a tunable balance exponent. The spec's Alternatives Considered (§9) rejects three other shapes explicitly:
| Alternative | Why it was rejected |
|---|---|
Additive decay (score - c per hop) | Not scale-free, goes negative, composes badly with multiplicative weights. |
e^(-λd) exponential-of-sum | Ignores edge-type semantics unless λ varies per type, at which point it is the multiplicative model in log space, with worse ergonomics. |
Weighted-sum fusion αs + (1-α)d | Lets one factor fully compensate the other; a decay≈0 node could still rank top on similarity alone, violating the "both signals must be present" premise. |
| Reciprocal Rank Fusion | Rank-based, discards the magnitudes the planner's token-budget packing needs. |
| Learned fusion | No training signal yet for v1; revisit once the eval harness generates one. |
Design¶
Decay lives in the traversal code path, not the temporal engine: core-engine/src/graph/traversal.rs attaches decay(n) as BFS expands; fusion is a separate pure function in core-engine/src/query/fusion.rs, called from the query executor once both sim(n) (from the vector stage) and decay(n) (from the expand stage) are available for the same node.
flowchart LR
BFS["BFS expand<br/>(traversal.rs)"] -->|"decay(n) per node"| EXEC["Query executor"]
VEC["Partition search<br/>(partitions.rs)"] -->|"sim(n) per node"| EXEC
EXEC -->|"related nodes"| FUSE["fuse(sim, decay, alpha)<br/>(fusion.rs)"]
EXEC -->|"primaries"| PRIMARY["S_primary = sim<br/>(never fused)"]
FUSE --> SCORE["RelatedNode.score"]
PRIMARY --> SCORES["QueryResponse.scores"] Data model¶
DecayParams¶
pub struct DecayParams {
default_weight: f64, // (0, 1], default 0.7
floor: f64, // [0, 1), default 0.05
weights: HashMap<String, f64>, // per-edge-type overrides
}
fn weight_of(&self, edge_type: &str) -> f64 {
self.weights.get(edge_type).copied().unwrap_or(self.default_weight)
}
Traversal::new builds DecayParams::default(); the query executor instead builds it from config via Traversal::with_decay, so decay.default_weight, decay.floor, and decay.weights.* are deployment-tunable without a code change.
Score fields on outputs¶
| Type | Field | Meaning |
|---|---|---|
TraversedNode (graph/traversal.rs) | decay_score: Option<f64> | Raw decay along the BFS discovery path; Some(1.0) for roots. |
RelatedNode (query/executor.rs) | score: Option<f64> | Fused S = sim^α · decay^(1-α); present only on vector queries. |
QueryResponse (query/executor.rs) | scores: Option<Vec<f64>> | Primary similarity, parallel to records; S_primary = sim, never fused. |
score_parts: {sim, decay} from the spec's §6 sketch does not exist as built. Only the fused score (or, for primaries, the raw similarity in scores) is exposed; the per-component breakdown is deferred to the trace-viewer lane (spec §14 v0.3).
Algorithms & invariants¶
Decay: multiplicative per-hop, BFS shortest-path-first¶
For a node n reached from root r via a path of edges e₁ … e_d:
decay_p(n) = ∏ weight_of(type(eᵢ)) for i in 1..=d
decay(n) = the decay of the path BFS discovers first
Roots enter the frontier at depth = 0, decay = 1.0. Each hop multiplies the parent's decay by the crossed edge type's weight:
// core-engine/src/graph/traversal.rs
let neighbor_decay = node_decay * self.decay.weight_of(&edge.edge_type);
if neighbor_decay < self.decay.floor {
out.stats.pruned_by_floor += 1;
continue; // pruned pre-emit, never added to next_frontier or out.nodes
}
BFS is a max-approximation, not an exact max
The spec's data model (§5) defines decay(n) as the maximum over all discovered paths to n. BFS with a visited-set only guarantees that when hop-count order approximates weight order. Where custom weights invert that (a 2-hop strong path outscoring a 1-hop weak path), v1 accepts the BFS approximation rather than doing weight-ordered (Dijkstra-style, -log w) expansion. This is spec OQ-1, resolved for v1 in favor of the BFS latency guarantee; the discrepancy is measurable via a debug flag and revisited only if it proves material on real weight configs.
Two invariants the code enforces beyond the formula itself:
- Floor pruning happens before emission, not after. A path whose decay would fall below
flooris never added toout.nodes, never added to the next frontier, and is counted instats.pruned_by_floor, a counter kept separate from budget exhaustion (out.partial). Floor pruning is expected steady-state behavior, not a truncation warning. - The visited-set is first-discovered-wins. Because BFS processes the frontier in deterministic order (sorted by node-id bytes before each hop), a node is claimed by whichever path reaches it first in that deterministic order, which is the shortest path by hop count, approximating (not guaranteeing) the true max-decay path per the caveat above.
Decay chain example (default weights)¶
With the default weight 0.7 and no floor pruning triggered (the engine's own unit table, decay_unit_table in traversal.rs):
depth 0 (root): decay = 1.0
depth 1: decay = 1.0 * 0.7 = 0.7
depth 2: decay = 0.7 * 0.7 = 0.49
depth 3: decay = 0.49 * 0.7 = 0.343 (still ≥ ε = 0.05)
With default weights, the floor never binds within the depth-3 hard cap (0.343 ≥ 0.05); it only binds under custom low weights. A worked example from the same test suite: two hops of a custom WEAK edge type weighted 0.1 gives depth 1 = 0.1 (survives, since 0.1 ≥ 0.05) and depth 2 = 0.01 (pruned, since 0.01 < 0.05): exactly one path pruned, stats.pruned_by_floor == 1, and partial stays false because floor pruning is not budget truncation.
Fusion: geometric mean under a balance exponent¶
// core-engine/src/query/fusion.rs
pub fn fuse(sim: f64, decay: f64, alpha: f64) -> f64 {
let alpha = alpha.clamp(0.0, 1.0);
let sim = sim.clamp(0.0, 1.0);
if alpha == 0.0 { return decay; } // fast-path precedes pow: 0^0 never evaluated
if alpha == 1.0 { return sim; } // fast-path precedes pow
sim.powf(alpha) * decay.powf(1.0 - alpha)
}
Both guards are normative, not incidental optimizations (spec §5, F7 review condition 2): the α ∈ {0, 1} fast-paths run before any powf call, so the language runtime's pow(0, 0) convention is never relied upon. sim is clamped to [0, 1] before fusion, so an un-normalized or slightly-over-1.0 similarity can never inflate a score past what a perfect match would produce.
The geometric-mean shape is deliberate: a zero-ish factor should crater the combined score. A semantically perfect but graph-distant node must not dominate on similarity alone, and a structurally close but semantically irrelevant node must not dominate on decay alone. This is exactly the property the rejected weighted-sum alternative lacked.
Primaries are never fused¶
This is F7 review condition 1 (spec §5 v0.2 correction): substituting decay = 1 into the fusion formula for a primary result would give S = sim^α ≠ sim whenever α ≠ 1, which would artificially inflate weak matches rather than leave them alone. The fix is not a numeric substitution but a structural bypass: primaries rank by sim directly, fusion applies only to expanded (related) nodes.
// core-engine/src/query/executor.rs: primaries
let primary: Vec<f64> = records.iter()
.map(|r| sim_map.get(&r.logical_id).copied().unwrap_or(baseline))
.collect();
// ... sorted descending by primary[i], stored as QueryResponse.scores
// related nodes: the only place fuse() is called
node.score = Some(crate::query::fusion::fuse(sim, decay, alpha));
A node without an embedding (absent from the partition search's hits) takes sim = baseline, where baseline = stage.min_score.unwrap_or(0.0): it participates in ranking but cannot outrank a true match, since min_score is a floor, not a typical similarity value.
Vector-only queries bypass fusion structurally¶
A query with a vector clause and no expand clause has no related nodes to fuse: fuse() is simply never called, because there is nothing in the related groups to call it on. This is not implemented as "substitute decay = 1" (which the F7 review explicitly rejected, per the primaries case above) but falls out structurally from primaries never being fused and there being no expansion to produce related nodes. Symmetrically, a query with expand but no vector clause never enters the vector stage at all (plan.vector is None), so ranking falls back to decay alone by simply never overwriting node.score.
Configuration¶
| Key | Default | Bounds enforced | Where |
|---|---|---|---|
decay.default_weight | 0.7 | (0, 1], validated at config load | TelhaConfig::validate |
decay.floor | 0.05 | [0, 1), validated at config load | TelhaConfig::validate |
decay.weights.{EDGE_TYPE} | {} (empty) | Each override (0, 1], validated at config load | TelhaConfig::validate |
decay.fusion_alpha | 0.6 | Not bounds-checked at config load | DecayConfig |
vector.alpha (per-query override) | inherits decay.fusion_alpha | [0, 1], validated in ast.rs | Query parse/validate |
The config key is decay.fusion_alpha, not fusion.alpha
The spec's §6 API Contracts prose still names the key fusion.alpha. As built, it lives under DecayConfig as decay.fusion_alpha (core-engine/src/config.rs), recorded as an explicit as-built rename in the spec's own §14 v0.3 changelog entry. decay.fusion_alpha is on the environment-override whitelist (ENV_KEYS includes "decay.fusion_alpha" alongside "decay.default_weight" and "decay.floor"), unlike embedding.* (see Vector storage).
decay.fusion_alpha itself has no (0.0..=1.0) range check in TelhaConfig::validate (unlike default_weight, floor, and every per-edge-type weight override, which are all validated). The per-query override vector.alpha is validated to [0, 1] at parse time in core-engine/src/query/ast.rs, and fuse() itself unconditionally clamps whatever alpha it receives to [0, 1] as a last line of defense. In practice a malformed decay.fusion_alpha in a config file cannot corrupt a score outside (0, 1], but it also isn't caught early with a clear config error the way the other three decay keys are.
As-built notes¶
From spec §14 (v0.2 F7 sign-off and v0.3 PR-040 as-built), reconciled against core-engine/src/graph/traversal.rs and core-engine/src/query/fusion.rs:
- Decay implementation location: the spec's architecture (§4) describes decay as attached during traversal; as built it lives in
graph/traversal.rs, not undertemporal/, confirming decay is a graph-expansion concern, not a temporal-versioning one. - F7 review conditions both implemented exactly as the changelog describes: the
α ∈ {0, 1}fast-paths precedepowf(guard 2), and vector-only queries bypass fusion structurally rather than via adecay = 1substitution (guard 1). Both are visible directly infuse()and in how the executor only callsfuse()onrelatednodes. fusion.alpha→decay.fusion_alpharename: confirmed inconfig.rs; the spec's own v0.3 changelog entry records this as an as-built deviation from its §6 prose, not an inconsistency to reconcile silently.score_partsdeferred: confirmed absent fromRelatedNodeandQueryResponse; onlyscore/scoresexist. The v0.3 changelog records this as intentionally deferred to the trace-viewer lane, not an oversight.- Property tests confirmed present:
fusion.rshasunit_table(hand-computed cases including both fast-paths),sim_clamped_before_fusion,monotonicity_properties(sweepingsim,decay, andalphaover a 0.1-step grid), andoutput_bounded_when_inputs_bounded.traversal.rshasdecay_unit_table(the chain example above, byte-verified to1e-12) anddecay_custom_weights_and_floor_pruning. - Not directly verifiable from this code alone: the spec's success metric "golden ranking fixtures: hand-computed scores on a 12-node reference graph, byte-stable" was not located as a distinct fixture file separate from the unit tests described above; the unit tables in
fusion.rsandtraversal.rscover the same normative cases (fast-paths, floor arithmetic, monotonicity) but were not cross-checked against a standalone 12-node fixture during this review.
Related¶
- Concepts › Confidence & decay, the idea, without the formulas.
- Vector storage, where
sim(n)comes from. - HNSW partitioning, the partition search that produces similarity hits, including the point-query window workaround referenced above.
- Traversal semantics, the BFS expansion decay is attached to.