Skip to content

RFC-003: Read-Path Depth Routing

Field Value
Author Patrick Roland
Status Draft — adversarial review completed, changes requested
Created 2026-04-16
Last updated 2026-07-27
ZettelForge version v2.7.0 (design only; not implemented)
Related RFCs RFC-001 Conversational Entity Extractor · RFC-002 Universal LLM Provider Interface

Not implemented

RFC-003 is a design proposal. Re-verified on 2026-07-27 against ZettelForge v2.7.0: none of the proposed modules (quality_gate.py, query_profile.py, query_markers.py, retrieval/) exist in the codebase and config.default.yaml has no recall.depth_routing section. The current recall() runs the uniform System 1 pipeline on every query. This page documents the proposal and its review status.

Context

recall() applies the same retrieval pipeline to every query regardless of complexity. The v2.7.0 benchmark report records these historical baseline numbers from a DGX Spark ARM64 run:

  • CTI single-entity lookups: p50 111 ms, p95 ~200 ms, ~75% accuracy
  • LOCOMO multi-hop long-horizon: p50 1,240 ms, p95 2,282 ms
  • Intent classification (keyword path): < 1 ms

Two failure modes follow from these numbers.

Low-complexity queries overpay. A query like CVE-2024-1234 triggers the full pipeline — intent classification, entity extraction, vector retrieval, graph traversal at depth 2, entity-augmented pull, and cross-encoder reranking — when a single entity-index lookup would answer it. The historical CTI p50 is inflated by phases that contribute no signal for this query class.

High-complexity queries hit a ceiling. Multi-hop CTI attribution (for example, "which threat actor used CVE-2024-3400 in campaigns that targeted healthcare between Q3 2024 and Q1 2025?") requires crossing 3+ entity boundaries with temporal constraints. The current top-k blend (default k=10) with depth-2 graph traversal cannot guarantee coverage of the full causal chain. Notes that are individually low-similarity but collectively form the answer are dropped before reranking. This explains the 25% accuracy gap on CTI and the long-tail p95 on LOCOMO.

The 5-way intent classifier already identifies these query classes but only weights traversal policy, not depth.

Proposal

Introduce a Quality Gate on recall() that routes queries to one of two retrieval depths:

  • System 1 — the current blended-retrieval pipeline (fast, unchanged)
  • System 2 — a new exhaustive-scan path with pre-filtering and temporal-chunk scoring

The gate is deterministic, LLM-free on the fast path, and uses heuristic signals from the already-computed intent classification and entity extraction plus one additional cheap probe (vector top-1 score). A D-Mem (arXiv:2603.18631) finding grounds the approach: its multi-dimensional quality gate recovers 96.7% of full-deliberation performance while reducing computational cost.

Architecture

recall(query, ...)
  │
  ▼
IntentClassifier.classify(query)        # Existing — unchanged, <1ms
  │
  ▼
QueryProfile                            # New — cheap feature bundle
  ├─ intent + confidence
  ├─ query entities (from EntityExtractor)
  ├─ span markers (temporal, causal, multi-hop)
  └─ aggregation markers
  │
  ▼
QualityGate.route(profile) ──────┐      # New — deterministic, <5ms
  │                              │
  │ System 1                     │ System 2
  ▼                              ▼
System1Retriever          System2Retriever
= current recall() body   = candidate pre-filter
  (unchanged)               + temporal chunking
  │                         + chunk scoring
  │                         + within-chunk rerank
  │                              │
  └─── results ── upgrade? ──────┘
  │   (if System 1 score floor breached)
  ▼
List[MemoryNote]

Gate design: additive bounded scoring

The gate computes a score ∈ [0.0, 1.0] from cheap features. Queries above system2_threshold (default 0.70) route to System 2; the rest stay on System 1.

Signal Weight
CAUSAL intent prior +0.35
RELATIONAL intent prior +0.20
EXPLORATORY intent prior +0.20
TEMPORAL intent prior +0.10
Ambiguous intent (confidence < 0.4) +0.15
Multi-hop span markers +0.25
Temporal span markers +0.20
Causal chain markers +0.25
Aggregation markers +0.30
Comparison markers +0.15
Entity count ≥ 3 +0.10
Entity type count ≥ 2 +0.05

A fast-path shortcut forces System 1 unconditionally when: FACTUAL intent + entity count ≤ 1 + no multi-hop markers + no aggregation markers.

System 2 pipeline

Where System 1 scores and ranks individual notes, System 2 scores temporal chunks to catch "collectively true, individually weak" patterns.

QueryProfile
  │
  ▼
1. CandidateFilter (entity-index expansion + 1-hop KG neighbors
                    + time-window expansion + vector top-200 fallback)
  │ target: 200–500 candidates
  ▼
2. TemporalChunker (sort by created_at, bucket by hourly|daily|weekly)
  │
  ▼
3. ChunkScorer (coverage 0.30 + density 0.20 + causal 0.20
               + centrality 0.15 + recency 0.15)
  │ select top-M chunks (default 5)
  ▼
4. Within-chunk rerank (cross-encoder on selected chunks only,
                        same as System 1 but bounded to ~100 pairs)
  │
  ▼
List[MemoryNote] (deduped, capped at k)

Upgrade path

After System 1 completes, a second check can escalate to System 2 without re-running the gate:

  • Top vector score falls below system1_confidence_floor (default 0.40), or
  • CAUSAL/EXPLORATORY query returns fewer than 3 results

A config knob max_upgrade_rate (default 15%) caps the fraction of queries that upgrade in a rolling 100-query window. Chronic upgrades indicate the threshold is set too conservatively.

Public API: no change

recall(query, domain, k, include_links, exclude_superseded, include_expired) keeps its current signature and return type. Gate decisions appear in structured logs as gate_depth, gate_score, and gate_reasons.

Config

Ships disabled by default. To opt in after upgrading to the version that delivers this RFC:

# config.yaml
recall:
  depth_routing:
    enabled: true

    quality_gate:
      system2_threshold: 0.70        # fraction [0,1]; lower → more System 2
      system1_confidence_floor: 0.40 # upgrade if top score < this
      max_upgrade_rate: 0.15         # cap upgrade rate in rolling window

    system2:
      max_candidates: 500
      chunk_size: auto               # auto | hourly | daily | weekly
      max_chunks: 5
      chunk_weights:
        coverage: 0.30
        density: 0.20
        causal: 0.20
        centrality: 0.15
        recency: 0.15

New storage primitives

System 2 requires three new read methods on StorageBackend:

def get_entity_neighbors(
    self, etype: str, evalue: str, hop: int = 1, max_notes: int = 100,
) -> list[MemoryNote]: ...

def iterate_notes_in_range(
    self, start: str, end: str,
) -> Iterator[MemoryNote]: ...

def get_entity_degree(self, etype: str, evalue: str) -> int: ...

The proposal intends these to be SQLite read queries over existing note, KG, and entity indexes. The revised RFC still needs to prove the exact query plans before keeping the "no schema migrations required" claim.

Adversarial review

An adversarial review completed on 2026-04-16 returned REQUEST CHANGES with 4 blockers and 13 warnings.

Open review findings after the 2026-07-27 re-audit

B1. Motivating example misroutes to System 1. The query "which threat actor used CVE-2024-3400 in campaigns targeting healthcare between Q3 2024 and Q1 2025?" scores 0.40 under the proposed weights — below the 0.70 threshold. The pattern \bused by .+ (?:to|in|for|against)\b does not match "used CVE". The query that RFC-003 was designed to fix routes to the path it already fails on.

B2. meta["top_vector_score"] does not exist. The upgrade path reads this key, but no current API exposes that metadata object. VectorRetriever.retrieve() can return (MemoryNote, score) tuples when called with return_scores=True; the revised RFC still needs to define where the top score is carried into the post-System-1 upgrade check.

B3. System 2 will bust the 800 ms budget if LLM NER is used. Current recall() query extraction calls extract_all() with its default use_llm=False, so the shipped read path is regex-only. The proposed System 2 candidate filter still needs to pin candidate-note extraction to regex-only behavior so future implementation does not call the LLM NER path across 200–500 notes.

B4. The storage contract is still underspecified. SQLite now has indexes on notes.created_at, KG edge endpoints, and entity lookup columns, but the three proposed StorageBackend methods do not exist. The revised RFC needs to specify exact queries and any incidence-count index required for get_entity_degree().

Decision

Status: Draft — changes requested. Implementation remains deferred pending a revised RFC that resolves the findings above. See the adversarial review document for the full list of warnings and recommended fixes.

Migration

No migration is needed today because this RFC has not shipped. The verified v2.7.0 recall() path remains the uniform blended retrieval path. When the implementation lands, the gate ships disabled by default (enabled: false); existing deployments require no config changes to preserve current behavior.

Alternatives considered

Alternative Verdict
LLM-based router Rejected — adds 300–500 ms to every query; non-deterministic; LLM-free fast path is an explicit constraint
Learned classifier router Rejected for this RFC — no labeled dataset exists; deferred to a follow-up RFC once telemetry accumulates
Always-System-2 Rejected — 7× latency regression on the dominant query class
Depth as explicit recall() argument Rejected as sole mechanism — agents cannot reliably decide depth without gate heuristics
Deeper graph traversal in System 1 Rejected — traversal cost is exponential; does not address "collectively true, individually weak" pattern