S9 Spike — Cocoindex equal-hash idempotency
S9 Spike — Cocoindex equal-hash idempotency
Section titled “S9 Spike — Cocoindex equal-hash idempotency”Audit date: 13/05/2026 (S235 Wave 1)
Cocoindex version probed: 1.0.4 (PyPI head; >= 1.0.3 contract from CLAUDE.md)
Spike scope: verify @coco.fn(memo=True) short-circuit on content_hash equality across runs on KH-shaped content
Branch: content-items-investigation
Harness location (ephemeral, not committed): $TMPDIR/kh-s9-spike/ — i.e. /tmp/claude-501/kh-s9-spike/ for this session
Author: WP7 S9 sub-agent (S235 Wave 1)
Verdict: CONFIRMED with critical caveats. Cocoindex’s memo short-circuit works precisely as documented — but the substrate is (function-input-fingerprint, component-path), NOT content_hash as a free-floating concept. Closes I3 in 00-synthesis-v2 (PARTIAL — see §6 + §7). Sidecar v1 promotion gate is unblocked CONDITIONAL on the recommended @coco.fn shape in §7.
§1 — Hypothesis
Section titled “§1 — Hypothesis”Claim (from brief): cocoindex @coco.fn(memo=True) short-circuits when input content_hash is unchanged across runs, regardless of timestamp or unrelated metadata changes.
Specifically (operationalised):
- Given a function
process(content: str) -> ProcessedOutputdecorated with@coco.fn(memo=True) - Run pipeline once over a corpus of N items
- Modify metadata (e.g.
updated_at) but NOTcontentof M items - Re-run pipeline
- Verify:
process()invoked only on items whosecontent(hash) changed; M-item invocations short-circuited from memo.
Operative refinement made during the spike: the brief’s wording elides a key cocoindex semantic. Cocoindex memoises per function-input fingerprint — it does NOT inspect a “content_hash” field unless the function’s signature consumes only that field. We test two function shapes:
- Outer (file-keyed):
process_item(file: FileLike, outdir: pathlib.Path)— memo input includes the whole-file bytes (viaFileLike’s internal fingerprint). - Inner (content-keyed):
expensive_inner(content_text: str, source_id: str)andcontent_only_inner(content_text: str)— memo input is only the content field, ignoring metadata.
The hypothesis becomes:
- (H1) — file-keyed: memo invalidates on any byte change in the source file (mtime / SHA), including metadata edits that don’t touch
.content. Expected: hypothesis-incorrect — file-level memo invalidates on metadata-only edits. - (H2) — content-keyed: memo invalidates ONLY when
.contentchanges. Metadata-only edits HIT memo. Expected: hypothesis-correct.
§2 — Test harness setup
Section titled “§2 — Test harness setup”2.1 Environment
Section titled “2.1 Environment”- macOS arm64, Python 3.14.2 (Homebrew).
- Cocoindex 1.0.4 installed via
python3 -m pip install 'cocoindex>=1.0.3'(CLAUDE.md gotcha:dangerouslyDisableSandbox: truerequired for install + run). - LMDB ops-DB at
$TMPDIR/kh-s9-spike/coco_db/(set viaCOCOINDEX_DBenv var per cocoindex’s Settings.db_path semantics — required, not optional in 1.0.4). - No external services (no Postgres, no Anthropic, no Supabase). Pure file-in / file-out + LMDB.
2.2 Corpus shape (24 fixtures)
Section titled “2.2 Corpus shape (24 fixtures)”Generated by build_corpus.py. Shapes mirror KH’s content_items + q_a_pairs per supabase/types/database.types.ts (uuid id, title, content, content_type, workspace_id, created_at, updated_at, freshness etc.). No real data used.
| Bucket | Count | Notes |
|---|---|---|
corpus/content_items/c0000000-...-N.json (N=0..9) | 10 | UK English content (“organisation, colour, recognise”) |
corpus/q_a_pairs/q0000000-...-N.json (N=0..9) | 10 | Question + answer + composite content field. Items 5/6 are content-identical (collision test) |
corpus/content_items/c0000000-...-eeeeeeeeeee1.json | 1 | Edge: empty content ("") |
corpus/content_items/c0000000-...-eeeeeeeeeee2.json | 1 | Edge: UK English + unicode (£, é, naïve) |
corpus/content_items/c0000000-...-eeeeeeeeeea3.json | 1 | Edge: collision pair A |
corpus/content_items/c0000000-...-eeeeeeeeeeb3.json | 1 | Edge: collision pair B (byte-identical content, different ID + title) |
| Total | 24 |
2.3 Pipeline (flow.py, 135 lines — reproduced verbatim)
Section titled “2.3 Pipeline (flow.py, 135 lines — reproduced verbatim)”# (header trimmed for brevity — see harness file for full text)import cocoindex as cocofrom cocoindex.connectors import localfsfrom cocoindex.resources.file import FileLike, PatternFilePathMatcher
@coco.fn(memo=True)async def expensive_inner(content_text: str, source_id: str) -> str: """Memo key = (content_text, source_id). Metadata edits to source file that don't change .content WILL hit memo (content_text unchanged).""" _log_inner(source_id, sha256(content_text)[:12], len(content_text), "expensive_inner") return content_text.upper()
@coco.fn(memo=True)async def content_only_inner(content_text: str) -> str: """Memo key = (content_text,). No per-item discriminator.""" _log_inner("(no-id)", sha256(content_text)[:12], len(content_text), "content_only_inner") return content_text.lower()
@coco.fn(memo=True)async def process_item(file: FileLike, outdir: pathlib.Path) -> None: """OUTER transform — file-level memo. Inputs include FileLike (whole-file fingerprint), so metadata-only edits to source file STILL invalidate this memo.""" raw = await file.read_text() doc = json.loads(raw) content_text = str(doc.get("content", "")) source_id = str(doc.get("id", str(file.file_path.path))) _log_invocation(file.file_path.path, sha256(content_text)[:12], len(content_text))
upper = await expensive_inner(content_text, source_id) _lower = await content_only_inner(content_text)
transformed = json.dumps({...}) localfs.declare_file(outdir / (file.file_path.path.stem + ".processed.json"), transformed, create_parent_dirs=True)
@coco.fnasync def app_main(sourcedir: pathlib.Path, outdir: pathlib.Path) -> None: files = localfs.walk_dir( sourcedir, recursive=True, path_matcher=PatternFilePathMatcher(included_patterns=["**/*.json"]), ) await coco.mount_each(process_item, files.items(), outdir)
app = coco.App(coco.AppConfig(name="S9IdempotencySpike"), app_main, sourcedir=CORPUS, outdir=OUTDIR)Three-tier instrumentation:
invocations.log— one line perprocess_item(outer) call.inner_invocations.log— one line perexpensive_innerORcontent_only_innercall, labelled.- Cocoindex’s built-in
ComponentStats(visible via stdout progress line: “24 total | 10 reprocessed, 14 unchanged”) gives the engine’s view of cache hit/miss.
Cross-checking the two confirms that “unchanged” in cocoindex’s reporter ⇔ no @coco.fn body executed (memo hit).
2.4 Run sequence
Section titled “2.4 Run sequence”- Reset:
cocoindex drop flow.py -f+rm -rf out corpus invocations.log inner_invocations.log - Build corpus (
python3 build_corpus.py --edge-cases). - RUN 1 (baseline):
cocoindex update flow.py— expected: every fn fires N times. - RUN 2 (no-op): re-run without touching corpus — expected: 100% memo hit at all tiers.
- Mutate corpus (
python3 build_corpus.py --mutate): for items 0..9, even items (0,2,4,6,8) getupdated_atrewritten (metadata-only); odd items (1,3,5,7,9) get bothupdated_atrewritten ANDcontentmutated. - RUN 3 (mutation pass): expected per H1 — file-tier reprocesses all 10; per H2 — content-tier reprocesses only 5.
§3 — Run 1 baseline (all-fresh invocation)
Section titled “§3 — Run 1 baseline (all-fresh invocation)”Cocoindex stdout (final stats):
✅ app_main: 1 total | 1 added✅ process_item: 24 total | 24 added⏳ Elapsed: 0.1sInvocation log counts:
| Function | Invocations | Notes |
|---|---|---|
process_item (outer, file-keyed memo) | 24 | One per corpus file |
expensive_inner (content+id memo) | 24 | One per outer invocation (called inline) |
content_only_inner (content-only memo) | 24 | One per outer invocation |
out/ files written | 24 | One .processed.json per fixture |
Timing: ~110 ms wall-clock for 24-item fresh pass. Engine startup (~100 ms) dominates; per-item work is negligible.
Engine’s view: all 24 source rows registered as “added” (new). LMDB Layer B (source-to-target key mapping) populated. LMDB Layer C (per-fn memo cache) populated for all three functions across 24 component instances.
§4 — Run 2 (no-op) + Run 3 (mutation pass)
Section titled “§4 — Run 2 (no-op) + Run 3 (mutation pass)”4.1 Run 2 — no changes
Section titled “4.1 Run 2 — no changes”Cocoindex stdout:
✅ app_main: 1 total | 1 reprocessed✅ process_item: 24 total | 24 unchangedNote: app_main reports “1 reprocessed” because cocoindex always re-invokes the top-level orchestrator to re-scan sources; the children memo-hit. This is the documented pattern.
Invocation log delta from RUN 1:
| Function | RUN 2 delta | Memo hit |
|---|---|---|
process_item | +0 | 100% (24/24 unchanged) |
expensive_inner | +0 | 100% |
content_only_inner | +0 | 100% |
Cumulative counts: 24 / 24 / 24 (unchanged from RUN 1).
Verdict: Memo cache survives engine restart. Source CDC detects “no change” (LMDB Layer B fingerprint match). Function bodies do not execute.
4.2 Run 3 — mutation pass
Section titled “4.2 Run 3 — mutation pass”Mutation applied by build_corpus.py --mutate:
- Even items 0,2,4,6,8 (5 files): rewrite
.updated_atonly..contentbyte-identical to baseline. - Odd items 1,3,5,7,9 (5 files): rewrite
.updated_atAND mutate.content(append “[MUTATED at]”). - All 14 other fixtures (q_a_pairs + edge cases) untouched.
Cocoindex stdout:
✅ app_main: 1 total | 1 reprocessed✅ process_item: 24 total | 10 reprocessed, 14 unchangedPer-item invocation table (10 mutated items only — the other 14 had no log delta):
| Source item | File SHA changed? | content_text changed? | Outer fired? | expensive_inner fired? | content_only_inner fired? | Reasoning |
|---|---|---|---|---|---|---|
| c…0000 (even) | YES (updated_at rewrite) | NO | YES — file fingerprint changed | NO — memo HIT | NO — memo HIT | File-tier invalidates; content-tier hits |
| c…0001 (odd) | YES | YES | YES | YES — memo MISS | YES — memo MISS | All tiers invalidate |
| c…0002 (even) | YES | NO | YES | NO — HIT | NO — HIT | Same as 0000 |
| c…0003 (odd) | YES | YES | YES | YES | YES | Same as 0001 |
| c…0004 (even) | YES | NO | YES | NO — HIT | NO — HIT | |
| c…0005 (odd) | YES | YES | YES | YES | YES | |
| c…0006 (even) | YES | NO | YES | NO — HIT | NO — HIT | |
| c…0007 (odd) | YES | YES | YES | YES | YES | |
| c…0008 (even) | YES | NO | YES | NO — HIT | NO — HIT | |
| c…0009 (odd) | YES | YES | YES | YES | YES |
Cumulative counts after RUN 3:
| Function | Total | RUN 3 delta |
|---|---|---|
process_item (outer) | 34 | +10 (all 10 mutated files) |
expensive_inner (content+id) | 29 | +5 (only the 5 content-changed items) |
content_only_inner (content) | 29 | +5 (only the 5 content-changed items) |
Verbatim invocation log delta (RUN 3 only, lines 25-29 of inner_invocations.log):
2026-05-13T23:06:01 expensive_inner c0000000-0000-4000-8000-000000000003 hash=1d0ac8931fcd len=1142026-05-13T23:06:01 expensive_inner c0000000-0000-4000-8000-000000000009 hash=8620f9414599 len=1142026-05-13T23:06:02 expensive_inner c0000000-0000-4000-8000-000000000005 hash=e032a2d44d42 len=1142026-05-13T23:06:02 expensive_inner c0000000-0000-4000-8000-000000000007 hash=30d44061ded5 len=1142026-05-13T23:06:02 expensive_inner c0000000-0000-4000-8000-000000000001 hash=300294f0b543 len=114Exactly items 1, 3, 5, 7, 9 — the content-changed set. Items 0, 2, 4, 6, 8 (metadata-only) absent → memo HIT confirmed.
H1 status: as predicted, the file-keyed memo invalidates on metadata edits (10/10 outer invocations). H2 status: CONFIRMED. The content-keyed memo short-circuits cleanly on content_text equality (5/5 hits on metadata-only items).
§5 — Edge case results
Section titled “§5 — Edge case results”All edge cases ran inside RUN 1 (and were verified unchanged in RUN 2 and untouched by mutation pass).
5.1 Empty content ("")
Section titled “5.1 Empty content ("")”| Aspect | Input | Expected | Actual | Verdict |
|---|---|---|---|---|
e1_empty fixture | content: "" | Process cleanly, hash = SHA256("") = e3b0c44298fc… | Logged: hash=e3b0c44298fc len=0. No exception. Output file c0000000-...eeeeeeeeeee1.processed.json written. Memo hit on RUN 2. | PASS |
Edge implication: cocoindex doesn’t reject zero-length inputs, and the hash for empty content is stable across runs.
5.2 Unicode + UK English
Section titled “5.2 Unicode + UK English”| Aspect | Input | Expected | Actual | Verdict |
|---|---|---|---|---|
e2_unicode content | "Organisation pays £1,200 quarterly; naïve approach. Colour, recognise, behaviour." (UTF-8, 81 bytes) | Hash stable across runs; content preserved through .upper() and writes | Logged: hash=dc81c7341057 len=81. Output file contains correctly-cased upper-form (“ORGANISATION PAYS £1,200 QUARTERLY…”). Memo hit on RUN 2 (no second invocation logged). | PASS |
Edge implication: SHA256 over UTF-8 bytes is stable; cocoindex’s memo fingerprint (which uses msgpack-style canonical serialisation per _internal/memo_fingerprint.py) treats UTF-8 strings consistently.
5.3 Identical content under different IDs (content collision)
Section titled “5.3 Identical content under different IDs (content collision)”Two collision pairs in the corpus:
e3a/e3b(content_items): identicalcontent“Identical body for collision test.” (34 bytes, SHA3dbc6f0ca9b8…), different IDs + titles.q5/q6(q_a_pairs): identicalcontentfield (133 bytes, SHAe2684c64e4e8…), different IDs.
Observations from RUN 1 (both pairs):
| Function | Behaviour |
|---|---|
process_item (outer) | Both items invoked separately. Each file is its own mount_each component → independent component-paths → independent memo namespaces. No cross-file dedup. |
expensive_inner(content_text, source_id) | Both invoked. Memo key is (content_text, source_id) — different source_ids → different keys → no collision. |
content_only_inner(content_text) | Both invoked. Despite identical input args, memo does NOT collide across mount_each-component instances. Each per-file component has its own memo bucket. |
| Aspect | Input | Expected (per brief) | Actual | Verdict |
|---|---|---|---|---|
Memo collision across mount_each siblings | content_only_inner("Identical body…") called from two parallel components | Either: memo collides (shared bucket) OR scopes per-key (independent buckets) | Scopes per-component. Each file-component has its own memo cache for inner functions. | Scope-per-component (NOT global hash collision) |
This is a critical finding for KH architecture. See §7 implications.
Confirmation test: Added a fourth fixture eeeeeeeefff1 (identical content to e3a/e3b) and re-ran. The new file’s content_only_inner invocation fired (memo miss) despite the identical content. Memo did NOT collide. Per-component scope confirmed.
5.4 Version bump (@coco.fn(memo=True, version=N))
Section titled “5.4 Version bump (@coco.fn(memo=True, version=N))”Bumped content_only_inner from version=1 to version=2 and re-ran:
| Function | Pre-bump count | Post-bump delta | Verdict |
|---|---|---|---|
process_item (outer, version unchanged) | 35 | +25 (full re-invoke) | Outer’s logic-fingerprint depends on its callee’s fingerprint (cocoindex propagates fingerprint hashes upstream). When content_only_inner version bumped → process_item fingerprint invalidates → all 25 outer invocations re-run |
expensive_inner (unchanged) | 30 | +0 | Independent fingerprint — survives unrelated version bump |
content_only_inner (version=2) | 30 | +25 | Full re-invocation |
Implication: version bumps cascade upstream through the call graph. Useful for KH (intentional reprocessing of a downstream extraction triggers all upstream callers), but worth flagging — a granular version bump on extract_q_a would force re-classification at every parent component.
§6 — Verdict
Section titled “§6 — Verdict”Hypothesis status
Section titled “Hypothesis status”| Hypothesis tier | Status | Confidence |
|---|---|---|
H1 — file-keyed @coco.fn(memo=True) short-circuits on metadata-only edits | REFUTED — file-tier memo invalidates whenever the source file’s bytes change, regardless of whether the .content field within changed | 99% |
H2 — content-keyed @coco.fn(memo=True) short-circuits on metadata-only edits | CONFIRMED — when the function’s input fingerprint depends only on the content_text, metadata-only edits hit memo cleanly | 99% |
| Original brief hypothesis as worded (“memo short-circuits on content_hash equality regardless of timestamp”) | CONFIRMED, BUT REQUIRES THE FN SIGNATURE TO EXPRESS THE CONTENT INPUT EXPLICITLY — not a property of the file source binding, a property of the function input. | 99% |
Caveats
Section titled “Caveats”| Caveat | Detail |
|---|---|
| Dev-mode vs prod-mode | Spike used cocoindex update flow.py (catch-up mode). Live mode (-L) uses the same memo substrate. No behavioural drift observed between modes per S2 / S14 prior spikes. Confidence: high. |
| In-memory vs LMDB-backed cache | Spike used LMDB-backed cache (default + only option for cocoindex update). Per _internal/memo_fingerprint.py source, memo lookups always go through LMDB Layer C. No in-memory fallback exists; cache survives process restart (verified — RUN 2 followed RUN 1 in a fresh CLI process). |
| Cross-component memo collision | Spike confirms memo is per-component, not per-content-hash. Two mount_each components with identical content_text both invoke the inner fn. This means KH cannot rely on global content-hash deduplication via memo. A separate dedup mechanism is required if global content-hash dedup is desired (e.g. a join pass that surfaces collisions, or cocoindex.ops.entity_resolution). |
| Component-path stability | If file paths change (e.g. fixture rename), the per-component cache is lost. KH q_a_pair sidecar files keyed on stable UUIDs (per Prereq 1 §4.4) inherit stability. |
| Cocoindex version | 1.0.4 head. CLAUDE.md gotcha contract is 1.0.3. Memo semantics unchanged between these versions per release notes (no breaking changes flagged). |
Confidence
Section titled “Confidence”Overall verdict (hypothesis confirmed, with the function-signature shape caveat): 95%.
Why not 99%:
- Tested at small N (24 fixtures). Behaviour at 1000+ items not verified — but memo substrate is content-fingerprint-based, not N-dependent.
- LMDB persistence across multi-day / multi-host runs not tested. Single-process, single-CLI-invocation cycle confirmed.
- No multi-process concurrent-writer test (covered by S14, not in-scope here).
§7 — Implications for KH
Section titled “§7 — Implications for KH”7.1 — I3 in 00-synthesis-v2 closure status
Section titled “7.1 — I3 in 00-synthesis-v2 closure status”PARTIAL. The empirical answer to “does @coco.fn(memo=True) short-circuit on equal-hash inputs?” is YES — but only when the function’s signature consumes the content directly, not when it consumes a file wrapper that carries metadata. KH must design the Q&A markdown sidecar pipeline accordingly:
| Layer | Recommended fn shape | Memo behaviour | Why |
|---|---|---|---|
| Outer (source-binding) | process_sidecar_file(file: FileLike, …) | Invalidates on any source-file byte change | Source-CDC tier; fine if KH wants source-level audit trail per edit |
Middle (parse / extract .content) | parse_q_a_sidecar(file: FileLike) -> ParsedQa (memo=True) | Same as outer — invalidates on metadata edit | OK if intentional; KH likely wants the parse to memo on body content |
| Inner (expensive ops — LLM extraction / embedding) | extract_q_a(content_text: str, …) -> ExtractedQa (memo=True) | HITS memo on metadata-only edits. Only re-runs when content_text differs. | This is the load-bearing tier — costs depend on LLM calls. MUST be content-keyed. |
Sidecar v1 promotion gate: RESOLVED — UNBLOCKED, CONDITIONAL on the layered fn shape above. The brief asked whether memo short-circuits cleanly enough that the sidecar pattern can promote to v1. Answer: yes, provided the expensive inner extraction functions take content_text as a string arg, NOT a FileLike.
7.2 — Recommended @coco.fn shape for KH content_items processor
Section titled “7.2 — Recommended @coco.fn shape for KH content_items processor”@coco.fn(memo=True)async def extract_classification(content_text: str, content_type: str) -> ClassificationResult: """LLM call. Memo invalidates only when content_text or content_type change. Metadata edits to source file (updated_at, owner_change, etc.) HIT memo.""" return await llm_classify(content_text, content_type)
@coco.fn(memo=True)async def embed_content(content_text: str) -> EmbeddingVector: """Embedding call. Memo invalidates only on content change.""" return await embedder.embed(content_text)
@coco.fn(memo=True)async def process_content_item(file: FileLike, table: TableTarget) -> None: """Outer: file-tier. Inner fns content-tier.""" doc = json.loads(await file.read_text()) classification = await extract_classification(doc["content"], doc["content_type"]) embedding = await embed_content(doc["content"]) table.declare_row(...)Cost-savings projection:
- KH’s expensive ops are LLM classification (
$0.001/item via Anthropic) and embedding ($0.0001/item via OpenAI). At 75k content_items × ~2 LLM calls + 1 embedding = ~$225 fresh-ingest cost. - Re-runs after metadata-only edits (owner changes, freshness rollups, etc.) cost $0 at the expensive-fn tier with the content-keyed shape.
- Without the layered shape (i.e. if all logic lives in a single
FileLike-input fn), every metadata edit triggers full reprocessing.
7.3 — Cross-content-hash dedup is NOT a memo concern
Section titled “7.3 — Cross-content-hash dedup is NOT a memo concern”Spike confirms memo is per-component, not per-content-hash. If KH wants to detect “two q_a_pairs with byte-identical content” (the duplicate-detection use case), this must be handled at a separate layer (e.g. a join pass against q_a_pairs.content_hash column in Postgres, or cocoindex.ops.entity_resolution). Confirms Prereq 2’s recommendation: Q&A pair dedup uses KH-side dedup_status + content_hash join — NOT cocoindex memo.
7.4 — Version bumps cascade
Section titled “7.4 — Version bumps cascade”When KH bumps version=N on a downstream inner fn (e.g. to release a new LLM prompt for classification), the cascade invalidates all upstream callers’ memo too. This is the documented behavior_version semantics. Practical implication: KH’s “re-classify all content_items with the new prompt” workflow becomes:
- Bump
version=on the leaf extraction fn. - Run
cocoindex update. All upstream callers invalidate; full reprocess of dependents.
Cleaner than building a KH-side reclassification_jobs queue. Aligns with Recommendation 4 in Prereq 2 (retire what cocoindex absorbs).
§8 — Limitations
Section titled “§8 — Limitations”8.1 — What the spike did NOT verify
Section titled “8.1 — What the spike did NOT verify”| Limitation | Detail | Follow-up |
|---|---|---|
| Multi-process concurrent writers | Spike ran single-process CLI. Concurrent cocoindex update invocations against the same LMDB not tested. | Covered by S14 spike (clean crash recovery + single-orchestrator topology). No re-test needed for v1. |
| Cross-host persistence | LMDB is local-filesystem only. Cloud Run instance restart with mounted persistent volume not tested. | Phase 2 first-step. Aligns with phase-b-prerequisite-2b §3.5 (multi-instance consolidated view = NOT AVAILABLE at v1). |
| Postgres target binding | Spike used localfs.declare_file sink, not postgres.mount_table_target(managed_by="user"). The S1 spike already validated the Postgres path; this spike isolates the memo behaviour from target-binding effects. | No re-test needed — orthogonal concerns. |
| Million-item scale | 24 fixtures. Memo lookup cost at 75k+ items not measured. | Per the cocoindex docs, LMDB lookup is O(log N); should scale linearly with item count + LLM cost. Not a memo-substrate question — a connector-throughput question. |
MemoStateOutcome two-phase validation | Cocoindex’s two-phase memo validation (cheap state check + expensive content hash) per _internal/memo_fingerprint.py exists but wasn’t exercised by this spike’s fn shapes (no custom __coco_memo_state__ declared). | Phase 2 implementation will exercise naturally. |
@coco.fn deps parameter | The deps parameter (for hash-tracking external dependencies) untested. | Not needed for KH’s pure-content fns; deferred to Phase 2 if LLM-prompt-template or model-version tracking is wanted. |
| Discriminated-union Pydantic with ExtractByLlm | Per phase-b-prerequisite-2-cocoindex-deep-dive §5 — separate STILL-OPEN. Not addressed here. | Independent spike per Prereq 2 §5. |
8.2 — What this spike CONFIRMS for KH Phase B
Section titled “8.2 — What this spike CONFIRMS for KH Phase B”| Claim | Status |
|---|---|
| Cocoindex memo short-circuits cleanly when input fingerprint is unchanged across runs | ✅ Confirmed empirically |
| The fingerprint includes ALL function args (not magic content-hash) | ✅ Confirmed |
FileLike input fingerprint = whole-file SHA → metadata edits invalidate | ✅ Confirmed |
str content_text input fingerprint = content SHA → metadata edits hit memo | ✅ Confirmed |
Memo is per-component (per-mount_each instance), not global per-content-hash | ✅ Confirmed |
| Version-bump cascade upstream | ✅ Confirmed |
| Edge cases (empty, unicode, collision pairs) handled cleanly | ✅ Confirmed |
8.3 — Reproducibility
Section titled “8.3 — Reproducibility”To re-run this spike from scratch (~5 minutes wall-clock):
mkdir -p $TMPDIR/kh-s9-spikecd $TMPDIR/kh-s9-spikepython3 -m venv .venv && source .venv/bin/activatePIP_USER=0 PIP_TARGET="" pip install 'cocoindex>=1.0.3'# Place build_corpus.py, flow.py from this spike's harness here.python3 build_corpus.py --edge-casesCOCOINDEX_DB="$(pwd)/coco_db" cocoindex update flow.py# RUN 2 — no-opCOCOINDEX_DB="$(pwd)/coco_db" cocoindex update flow.py# Mutate + RUN 3python3 build_corpus.py --mutateCOCOINDEX_DB="$(pwd)/coco_db" cocoindex update flow.py# Inspect countswc -l invocations.log inner_invocations.logHarness files preserved in $TMPDIR/kh-s9-spike/ for the session lifetime. Not committed to repo per CLAUDE.md temp-file policy. Future re-runs require reconstructing the harness from this report’s §2.3 listing (intentionally complete enough to retype).
End of S9 spike report. Closes I3 in 00-synthesis-v2.md (PARTIAL — caveats in §7). Unblocks sidecar v1 promotion gate CONDITIONAL on layered fn-shape recommended in §7.2. Confidence 95%. No still-open items requiring escalation to main session; recommendations route into WP4 02-data-flow.md + 05-qa-flow.md.