Skip to content

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 floor is never added to out.nodes, never added to the next frontier, and is counted in stats.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

S(n) = sim(n)^α · decay(n)^(1-α)        α ∈ [0, 1], default α = 0.6
// 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

S_primary(n) = sim(n)                    -- always, regardless of α

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 under temporal/, 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 precede powf (guard 2), and vector-only queries bypass fusion structurally rather than via a decay = 1 substitution (guard 1). Both are visible directly in fuse() and in how the executor only calls fuse() on related nodes.
  • fusion.alphadecay.fusion_alpha rename: confirmed in config.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_parts deferred: confirmed absent from RelatedNode and QueryResponse; only score / scores exist. The v0.3 changelog records this as intentionally deferred to the trace-viewer lane, not an oversight.
  • Property tests confirmed present: fusion.rs has unit_table (hand-computed cases including both fast-paths), sim_clamped_before_fusion, monotonicity_properties (sweeping sim, decay, and alpha over a 0.1-step grid), and output_bounded_when_inputs_bounded. traversal.rs has decay_unit_table (the chain example above, byte-verified to 1e-12) and decay_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.rs and traversal.rs cover the same normative cases (fast-paths, floor arithmetic, monotonicity) but were not cross-checked against a standalone 12-node fixture during this review.