9.4 KiB
Approach A — Python CLI as Sole Retrieval Owner
Date: 2026-07-10 Author: Main (synthesized from latency probes in failure matrix + architecture drift audit) Scope: Evaluate Approach A for the PaperForge Retrieval Experience: Python CLI is the only query/build execution owner. Plugin invokes a fresh CLI process for M, @, status, build, and control; sql.js is deleted; SQLite/FTS/sqlite-vec and build_state stay Python-owned. Non-goals: daemon process; sql.js; native Node SQLite module; Chroma migration.
Verdict
Viability: Strongly viable — the simplest correct path.
Recommendation: Implement as the first recovery phase regardless of which long-term architecture is chosen. Approach A is the only option that can restore retrieval correctness within a single session without introducing new IPC surface or lifecycle complexity. It can later be upgraded to Approach B (add sql.js as read-only cache) or Approach C (add worker) once correctness is proven.
Fatal risk: The only fatal risk is the same as today — contract drift between Python payloads and TypeScript parsers. This is mitigated by a unified PFResult envelope enforced by contract tests.
Exact ownership boundary
Python owns everything retrieval (canonical)
- Storage:
paperforge.db(FTS, vec0, meta, build_state) - Schema:
paperforge/memory/schema.py - M query execution:
paperforge search --json - @ query execution:
paperforge retrieve --deep --json - Build execution:
paperforge embed build|status|stop - Status/health:
paperforge embed status --json - Result envelope: one unified
PFResultacross all search/retrieve paths - Error taxonomy:
ok: falsewitherror.codeanderror.message
Plugin owns UI only
- spawn Python CLI per query
- parse one result envelope (
PFResult) - render cards, progress, errors
- manage debounce and abortion (kill child process)
Explicitly deleted
paperforge/plugin/src/services/db.ts(sql.js service)paperforge/plugin/sql-wasm.wasmsql.jsfrompackage.json- All sql.js initialization/fallback branches in dashboard
vector-runtime-state.jsonas authoritative UI truth (may remain as debug snapshot)- Legacy Chroma production gates in resume/force/delete/status
Observed latency
All measurements from the live Literature-hub vault (D:/L/OB/Literature-hub, 868 papers, 88.9 MB paperforge.db):
| Operation | Time | Notes |
|---|---|---|
Bare Python child spawn (python -c pass) |
73 ms | Floor cost per CLI call |
paperforge.cli import from fresh process |
116 ms | Import chain: config 18ms, db 28ms, search 6ms |
Full M metadata search spawn (search knee --json) |
131 ms | Cold end-to-end |
| Direct SQLite FTS5 JOIN query (5 results) | 16.4 ms | Query-only, no spawn |
| sqlite-vec extension load | 102.1 ms | One-time per process |
User-visible latency under Approach A
| Interaction | Expected latency | Perceived |
|---|---|---|
| Debounced M search (200ms timer) | ~130 ms from spawn | ~330 ms total (200ms debounce + 130ms spawn) |
| Enter-triggered M search | ~130 ms | Acceptable for explicit action |
| @ deep search (cold) | ~200-300 ms (spawn + vec load + API) | Acceptable |
| @ deep search (warm, sequential) | ~130 ms + API | Acceptable |
| Status/health check | ~130 ms | On settings load — fine |
| Build start | ~130 ms | One-time cost |
Is ~330ms debounce acceptable?
Yes. The current debounce is 200ms. Adding 130ms spawn gives ~330ms from last keystroke to results. This is within the 200-500ms range where users perceive "instant." The sql.js path was intended to skip the 130ms spawn, but it never worked (Row 1 — paper_fts.year missing), so the real user experience has always been CLI fallback anyway.
P0/P1 matrix resolution
| Row | How Approach A fixes it |
|---|---|
P0 R1: sql.js paper_fts.year |
Resolved by deletion. No JS-owned SQL. Python owns the query next to the schema. |
P0 R2: missing --deep flag |
Resolved. Plugin passes --deep for @ queries. One flag, one path. |
P0 R3: data.chunks vs matches |
Resolved by unification. Both search and retrieve return data.matches with stable field names. |
| P0 R5: delete-after-write bug | Must fix independently. Same Python build loop. Requires transaction-level fix. |
P0 R4: text vs matched_text |
Resolved by unification. One field name in the envelope. |
| P1 R6: resume gates on Chroma | Must fix in build path. Delete Chroma gating, check vec0 tables. |
| P1 R7: force deletes Chroma dir | Must fix in build path. Drop vec0 tables, not legacy dir. |
| P1 R8: dead PID stop unsettled | Must fix in build path. Cooperative cancellation + state settlement. |
| P1 R9: JSON vs SQLite split-brain | Resolved. Plugin reads embed status --json which queries live SQLite. JSON snapshot becomes debug-only. |
| P1 R10: meta-only health | Must fix in status path. Add vec0 queryability probe. |
Result envelope — unified PFResult
All retrieval paths (M search, @ deep) return the same outer shape:
{
"ok": true,
"data": {
"query": "knee",
"matches": [
{
"zotero_key": "...",
"title": "...",
"first_author": "...",
"year": 2024,
"journal": "...",
"domain": "...",
"abstract": "...",
"score": 0.95,
"text": "...",
"heading": "...",
"source": "fulltext"
}
]
},
"count": 5,
"warnings": []
}
Error envelope:
{
"ok": false,
"error": {
"code": "VECTOR_CORRUPTED",
"message": "Vector index is unreadable. Rebuild vectors before retrieving.",
"details": {}
}
}
Error codes: VECTOR_NOT_BUILT, VECTOR_CORRUPTED, MODEL_CHANGED, BACKEND_UNAVAILABLE, TIMEOUT, INTERNAL_ERROR, NO_PYTHON.
Build state flow
- Plugin spawns
paperforge embed build --resume(or--force). - Python writes
build_stateto SQLite as the only truth. - Plugin polls
paperforge embed status --jsonperiodically during build. embed statusreads live SQLitebuild_stateand vec0 queryability, returns fresh PFResult.- Plugin renders progress bar and state from the status response.
- On stop: plugin spawns
paperforge embed stop --json. Python kills PID, writesstatus=idleto SQLite, returns settlement confirmation. vector-runtime-state.jsonbecomes a debug-only snapshot written byembed statusfor human inspection. Plugin never reads it as control-plane truth.
Minimal implementation sequence
- Unify result envelope. Make
paperforge searchandpaperforge retrievereturn the samedata.matcheskey with the same field names (first_author,text). Add--deepflag plumbing to retrieve. - Fix plugin parser. Remove
matches/results/chunksforking. Parse onlydata.matches. Removematched_textcheck — usetext. - Fix plugin spawn. Pass
--deepfor@queries. Delete sql.js service. - Fix build path. Transactional write-then-delete. Delete Chroma gating from resume/force. Add vec0 queryability check to status. Cooperative stop with state settlement.
- Fix plugin state. Read live
embed status --jsoninstead of JSON snapshot. Poll during build. - Add contract tests. E2E: plugin spawn → CLI output → parse → render. Test every error code.
Approximate files affected
- Delete:
paperforge/plugin/src/services/db.ts,paperforge/plugin/sql-wasm.wasm - Modify plugin:
dashboard.ts(search spawn + parsing + state),settings.ts(build state),memory-state.ts(delete snapshot-as-truth),python-bridge.ts(unified spawn helper) - Modify Python:
search.py(unified envelope),retrieve.py(unified envelope +--deep),embed.py(fix build loop, Chroma cleanup, cooperative stop, status probe),build_state.py(canonical truth),status.py(vec0 queryability),_chroma.py(delete legacy production gates),state_snapshot.py(downgrade to debug-only) - New: contract test file for envelope schema
Comparison to Approaches B and C
| Dimension | A (CLI only) | B (sql.js cache) | C (worker) |
|---|---|---|---|
| Correctness risk | Lowest — one code path | Medium — staleness risk | Medium — IPC/lifecycle risk |
| Implementation complexity | Lowest | Medium | High |
| M search latency | ~330ms (debounce+spawn) | ~200ms (sql.js hit) | ~10-25ms (worker hit) |
| @ search latency | ~200-300ms | ~200-300ms | ~130ms + API |
| New IPC surface | None | None | JSON-lines protocol |
| Ownership clarity | Best — one owner | Good — two readers | Good — two workers |
| Upgrade path | Baseline | From A → B | From A → C |
| Files changed | ~12 | ~16 | ~22 + 4 new |
Final verdict
Approach A is the correct first recovery phase. It restores retrieval correctness with the smallest diff and no new lifecycle complexity. It establishes the unified PFResult envelope and Python-as-canonical-owner contracts that both B and C also require. Once correctness is proven in the live vault with contract tests, the team can decide whether to add Approach B (sql.js read cache) or Approach C (panel-scoped worker) as a latency optimization.
Bottom line: Implement Approach A now. Defer B and C until after correctness is restored and contract tests gate the live pipeline.