# Design Memo — Knowledge Cascade (v0.3)

> **Status**: outline / feasibility study, not implementation commitment yet.
> **Target cycle**: v0.3 (Platform Skeleton).
> **Author**: 2026-05-09 design session.
> **Trigger**: #2 file management tab — user asked "if I delete a file,
> do its derived entities and relations also disappear?". Current data
> model can't answer correctly. This memo plans the proper fix for v0.3.

---

## 0. TL;DR

When an admin deletes (or modifies) an uploaded source document via
the `/admin/files/...` tab, three derived artefacts must update in
lockstep with the file:

1. **Vector chunks** — already cleaned by `delete_by_source(filename)`.
2. **Entity files** — partially cleaned by `delete_entity()`, but
   leaves orphan back-references in other entities.
3. **Relation confidences** — *not addressable today*. The relation
   schema has a single `confidence: float` with no provenance. Two
   docs that both reinforce a relation can't be undone independently.

This memo defines a v0.3 work block that:

- adds a `sources: [{doc_id, weight, role, ts}]` provenance field to every
  relation;
- recomputes `confidence` from `sources` (derived, not stored);
- routes file delete/modify through a cascade that mutates `sources`
  precisely (no silent data loss);
- exposes manual relation edits via a graph-editor mode on `/admin/graph`
  using the same `sources` mechanism (`role: "manual"`).

The work is split into **5 phases (5 PRs)** that can land
independently. Phase A is reversible, Phase B onward depends on A.

**Why now**: the v0.3 plugin skeleton is the right time. We're already
extracting `packs/general/` content — fixing the relation schema
during that extraction is one less migration burden later.

**Why not v0.2**: 36 PR feedback cycle is in flight. Schema migrations
collide with active feature work and risk corrupting the wiki the user
is actively building.

---

## 1. Goals

| # | Goal | Measure |
|---|---|---|
| G1 | Deleting a source document removes its contribution to every relation it spawned, without affecting contributions from other docs | Confidence decreases (or relation removed) iff the deleted doc was a source |
| G2 | Modifying a source document re-extracts and updates only the affected relations | Unchanged relations keep their original `sources[].ts`; new/removed entities trigger surgical add/remove |
| G3 | Manual edits in graph editor co-exist with auto-extracted relations | `sources[*].role` distinguishes `extract` / `inverse` / `manual`; manual entries survive doc deletion |
| G4 | STEP 7 baseline preserved across migration | After Phase A, `python scripts/bench.py --check` passes byte-identical relative to a fresh capture |
| G5 | Migration is reversible | Phase A keeps a frozen pre-migration snapshot; rollback script restores |

## 2. Non-goals

- Full version history of every relation (use git for entity files if needed)
- Per-source weight elaboration beyond a scalar (`{doc_id, weight}` is enough)
- Retroactive recovery of pre-migration backref provenance — backrefs were
  added by `migrate_inverse_relations.py` without source tracking, so for
  legacy edges we'll mark `role: "legacy"` and skip cascade
- Rebuilding confidence from raw text similarity — the LLM-extracted weight
  at ingest time is the source of truth

---

## 3. Data Model Change (Phase A)

### Today

```yaml
# wiki/entity/prod/concept/비트코인.md (excerpt)
relations:
- target: Strategy(MSTR)
  target_id: e_org_afcc8484
  target_type: org
  type: RELATED_TO
  label: 관련
  confidence: 0.9
  inferred: true
```

### Proposed

```yaml
relations:
- target: Strategy(MSTR)
  target_id: e_org_afcc8484
  target_type: org
  type: RELATED_TO
  label: 관련
  confidence: 0.9                      # DERIVED — recomputed from `sources`
  inferred: true
  sources:                              # NEW — source of truth
    - doc_id: e_document_a629d17f      # "05_Strategy매입중단_Q1실적임박"
      weight: 0.7
      role: extract                     # LLM extracted from this doc
      ts: '2026-05-05T16:36:51'
    - doc_id: e_document_ebf1f98b      # "01_ETF_단일일최강유입_5월1일"
      weight: 0.7
      role: inverse                     # backref from doc → entity migration
      ts: '2026-05-05T16:36:51'
    - doc_id: null
      weight: 0.85
      role: manual                      # admin edit via graph editor
      author: admin
      ts: '2026-06-01T12:00:00'
```

### Confidence formula

`confidence` is **derived**, not stored:

```python
def derive_confidence(sources: list[dict]) -> float:
    """Probabilistic OR — any source can confirm the relation.
    
    P(confirmed) = 1 - Π(1 - w_i)
    
    Properties:
      - 0 sources → 0
      - 1 source w → w
      - many sources → asymptotic to 1, but never exceeds 1
      - matches user intuition "more docs say it = more confident"
      - cascading delete strictly decreases confidence (monotonic)
    """
    if not sources:
        return 0.0
    p = 1.0
    for s in sources:
        w = max(0.0, min(1.0, float(s.get("weight", 0))))
        p *= (1.0 - w)
    return round(1.0 - p, 4)
```

**Why log-sum, not max or average:**

| Formula | Property when adding a new source | Property when removing a source |
|---|---|---|
| `max(weights)` | Stays the same (unless new is higher) | Drops only if removed was THE max |
| `mean(weights)` | Always changes — even if new is weaker, mean dilutes | Always changes |
| `log-sum (this)` | Always increases (monotone in count + weight) | Always decreases |

Log-sum is the only one with **monotone cascade semantics**: deleting a
doc strictly drops confidence, never leaves it stale.

### Migration plan (Phase A only — non-cascading)

1. Walk every entity in `wiki/`.
2. For every relation in every entity:
   - If `attributes.source_document` exists and matches a known doc entity →
     `sources: [{doc_id: that_doc, weight: <existing confidence>, role: extract}]`
   - Else if `inferred: true` → `sources: [{doc_id: null, weight: <existing confidence>, role: legacy}]`
     (we couldn't derive provenance for backfilled inverses, mark legacy)
   - Else → `sources: [{doc_id: null, weight: <existing confidence>, role: legacy}]`
3. `confidence` field is now redundant — leave it for back-compat (one
   release cycle), but downstream readers should use `derive_confidence(sources)`.
4. Optional: emit `entity_id_index_pre_v03.json` snapshot for rollback.

---

## 4. Ingestion Path Changes (Phase B)

`process_document_for_entities` becomes additive:

```
For each extracted (subject, predicate, object) triple:
  rel = find_or_create_relation(subject_entity, object_entity, predicate)
  rel.sources.append({
      doc_id: this_doc.entity_id,
      weight: llm_assigned_weight,
      role:   "extract",
      ts:     now(),
  })
  # confidence auto-derives via derive_confidence(rel.sources)
```

`migrate_inverse_relations.py` is **folded in**: at ingest time, the
inverse direction is added with `role: "inverse"` and the same doc_id.
The script itself becomes a one-shot Phase A migration tool, removed
after Phase A lands.

---

## 5. Delete Cascade (Phase C)

**Trigger**: `DELETE /admin/files/{root}/{path}` (new endpoint, gated
+ confirmed via UI dialog).

```
Procedure delete_doc(doc_filename):
  doc_entity_id = find_doc_entity_by_source(doc_filename)
  
  # Step 1 — find all entities with sources mentioning this doc.
  affected_entities = scan_all_entities_for_source(doc_entity_id)
  
  # Step 2 — for each affected relation, drop the doc from sources.
  for entity in affected_entities:
    for relation in entity.relations:
      relation.sources = [s for s in relation.sources
                          if s.doc_id != doc_entity_id]
      if not relation.sources:
        entity.relations.remove(relation)
  
  # Step 3 — orphan entity sweep.
  # An entity is orphaned if:
  #   - its attributes.source_document points to deleted doc, AND
  #   - no other entity has any relation TO it (via sources scan)
  for entity in entities_with_source_document(doc_filename):
    if not has_incoming_references(entity.id):
      delete_entity(entity)
  
  # Step 4 — vector + doc entity + disk file
  vector_store.delete_by_source(doc_filename)
  delete_entity(doc_entity_id)
  os.remove(uploads_path/doc_filename)
  
  # Step 5 — audit
  audit("/admin/files/delete", details={
    "filename": doc_filename,
    "doc_entity": doc_entity_id,
    "affected_relations": <count>,
    "orphaned_entities": <count>,
  })
```

### Edge cases

- **Doc has manual relations** (sources contain `role: "manual"` from
  graph editor): manual sources are NOT removed by file delete. The
  relation persists with reduced confidence.
- **Doc is referenced by another doc's extraction** (rare — wiki_generator
  emits doc-doc relations only for explicit cross-references): handled
  same as any other relation (drop from sources).
- **Entity is the doc itself** (the doc-as-entity created by ingestion):
  always deleted (Step 4).
- **Backup**: file is moved to `uploads/.deleted/{ts}_{filename}` for
  N days before purge — symmetric with `delete_entity()`'s existing
  backup pattern.

---

## 6. Modify Cascade (Phase D)

**Trigger**: re-upload of an existing filename with new content
(server detects via existing UUID-prefix uniqueness).

```
Procedure modify_doc(doc_filename, new_content):
  old_extraction = load_cached_extraction(doc_filename)   # see below
  new_extraction = llm_extract(new_content)
  
  removed = old_extraction - new_extraction
  added   = new_extraction - old_extraction
  kept    = old_extraction & new_extraction
  
  for rel in removed:    # cascading delete on the removed entries only
    drop_source(rel, doc_filename)
  for rel in added:      # ingestion path on the added entries
    add_source(rel, doc_filename, weight, role="extract")
  # `kept` — leave alone (no sources mutation)
```

### Caching the old extraction

Phase D requires the previous extraction's triples to diff against.
Two options:

- **A**: store the extraction as a sidecar JSON next to the upload
  (`uploads/<uuid>_<filename>.extraction.json`). Cheap, simple,
  reversible.
- **B**: re-derive from the doc entity's relations + cross-references.
  Possible but lossy — backref relations spread across many entity
  files.

**Recommendation: A**. ~20 KB per doc, gitignored.

### Re-extraction trust boundary

A modified doc must re-pass the existing ingestion pipeline:
- `default_engine.sanitize_for_ingestion` (unchanged)
- size cap (unchanged)
- LLM extraction (re-run)

No bypass.

---

## 7. Graph Editor Integration (Phase E)

`/admin/graph` is currently read-only. Phase E adds an edit mode.

### UI changes (extends PR #143's lifecycle hooks)

- Toggle button on the answer card / overlay: **🔓 Edit Mode** (admin only)
- Edit mode active:
  - Click on edge → modal: source list, per-source weight, role badges,
    delete-source buttons, add-manual-source form
  - Click on node → modal: entity attributes + outgoing relations list,
    add/remove relation forms
  - Right-click context menu on any element

### Backend endpoints

```
PUT  /admin/graph/relation
  body: {src_entity_id, tgt_entity_id, relation_type, sources: [...]}
  Replaces the relation's sources, recomputes confidence, persists
  to both endpoints (forward + inverse).
  Adds an audit entry with the old + new sources for diff.

POST /admin/graph/relation/source
  body: {src_entity_id, tgt_entity_id, weight, role: "manual", note}
  Convenience: append a manual source without overwriting others.

DELETE /admin/graph/relation
  body: {src_entity_id, tgt_entity_id, relation_type}
  Removes the relation entirely (drops all sources).
```

### Trust model

- Edit mode requires `admin` role (server-side gate, not just UI).
- Every mutation hits the audit log with before+after `sources` arrays.
- Manual edits are **not** undone by file delete (their `role: manual`
  source is filtered out of the cascade).

### Graceful degradation

- If a relation is edited while a `/query/` is in flight, the in-flight
  query's reasoning is unaffected (it loaded the entity before the edit).
- Concurrent edits from two admins on the same relation: last-writer-wins
  on the relation as a whole; per-source append (POST /source) is
  commutative (no conflict possible).

---

## 8. Phasing

| Phase | Scope | Reversible? | Depends on |
|---|---|---|---|
| **A** | Schema migration: add `sources`, derive `confidence` | ✅ frozen pre-migration snapshot | nothing |
| **B** | Ingestion writes to `sources` instead of `confidence`-only | partially (re-run extractor) | A |
| **C** | Delete cascade in /admin/files/ | ✅ deleted file backed up to `.deleted/` for N days | A, B |
| **D** | Modify cascade with extraction diff | ✅ same backup pattern | A, B, C |
| **E** | Graph editor (edit mode + 3 endpoints) | ✅ all mutations audit-logged with diff | A, B (C/D not strictly required) |

Each phase = 1 PR. Phase A and Phase E can be parallelized once Phase B
is in. Phase C and D land sequentially.

---

## 9. Risks & Mitigations

| Risk | Mitigation |
|---|---|
| Migration corrupts wiki | Phase A writes to a separate `wiki_v03/` directory first, runs RAGAS comparison, then atomic swap |
| Confidence drift breaks STEP 7 baseline | Re-capture baseline as part of Phase A PR; treat any drift > ±2 graph_paths as a soft regression and document in CHANGELOG |
| `sources` array unbounded growth | Cap per relation at N=50 sources; on overflow, merge oldest two (sum weights via log-sum, average ts) |
| Two-phase commit on delete (file vs DB vs vector) | Step ordering: delete vector first (idempotent), then entities (with backup), then file. Failure mid-flight leaves orphans not silent loss; cleanup tool sweeps |
| LLM extraction nondeterminism on modify (Phase D) | Diff at the (subject, predicate, object) triple level, not at the JSON-blob level. Ignore minor weight perturbations < 0.1 |
| Graph editor UX complexity | Phase E ships behind a `JAMES_GRAPH_EDIT=1` env flag for the first release cycle; admin opt-in |

---

## 10. Dependencies / Pre-requisites

- ✅ v0.2 6 axes complete (current state, 2026-05-09)
- 🟡 v0.3 plugin skeleton in flight (provides natural "now is the time
  to migrate" pressure)
- ❌ Two-user external validation (Axis 6 gate) — recommended but not
  blocking; cascade is for the operator, not the second user
- ⚠️ Backup strategy for wiki/ is currently ad-hoc (single backup file
  per `delete_entity` call). Phase A should standardise on
  `wiki/.snapshots/{ts}/` for full-tree snapshots before any cascade.

---

## 11. Open Questions

1. **Manual sources never expire.** Should they? E.g. an admin marks a
   relation manually, then doc evidence accumulates contradicting it.
   Today: manual stays. Counter-proposal: weight decays after T days
   without renewal. **Decision needed before Phase E.**

2. **Cross-doc inferred edges (the #A5-D root cause).** When delete
   cascade runs, should it weaken inferred edges differently? E.g.,
   removing a doc that bridged Palantir → Bitcoin should immediately
   collapse the spurious relation. Today's `sources` model handles
   this naturally (the bridge doc's source weight = 0.7 → relation
   loses that contribution). **No decision needed; correctness
   emerges from the model.**

3. **Multi-tenancy interaction.** When v1.0 multi-tenancy lands, will
   `sources[].doc_id` need a tenant prefix? Probably yes — easier to
   add now than retrofit. **Recommend: add `sources[].tenant?` field
   in Phase A even though current single-tenant ignores it.**

4. **Should the ROADMAP.md `v0.3` section list this as a deliverable
   or leave it as design-stage?** It's a nontrivial scope; if listed
   in v0.3 deliverables, plugin skeleton timing slips. **Recommend:
   list as v0.3 deliverable but mark "may slip to v0.3.x patch" in the
   roadmap entry — calibrate expectations.**

---

## 12. Success Criteria

This memo's vision is realised when:

1. An admin uploads `report_2026Q1.pdf`, sees its entities + relations
   appear, then deletes it from `/admin/files/...`. Within seconds:
   - the file is gone from `uploads/`
   - vector chunks gone from ChromaDB
   - the doc entity gone from `wiki/`
   - any entity that **only** existed because of this doc is gone
   - any entity that existed before and was reinforced by this doc is
     **still there but with reduced confidence** matching the
     log-sum formula
   - relations that have manual edits **persist** with confidence
     reflecting only the manual + remaining doc sources
   - audit log entry shows the before/after `sources` lists for every
     mutated relation

2. STEP 7 baseline holds (within ±2 graph_paths band) before and
   after the migration in Phase A.

3. A second admin opening `/admin/graph` after Phase E can click any
   edge, see its full source history (which doc contributed what
   weight when), and surgically remove a single source if needed —
   without affecting the other sources.

4. Re-uploading a modified version of `report_2026Q1.pdf` produces the
   minimum diff: entities mentioned in both old and new content keep
   their `sources[]` ts unchanged for the relation entries that didn't
   change; only added/removed entities trigger source mutations.

---

## 13. Out of Scope (revisit post-v1.0)

- Per-relation confidence override that ignores the `sources` derivation
  (would defeat the whole provenance model)
- Federation: pulling source provenance from another JAMES instance's
  audit log
- Time-travel queries: "what did the graph look like a week ago?" —
  needs full event-sourced store, far beyond v0.3
- Batch "merge two duplicate entities" operation (would need careful
  source dedup); leave to manual admin scripts for now
- Automatic source weight calibration based on RAGAS feedback
  (interesting but premature; humans should set weights for v0.3)

---

## 14. Reference Implementation Sketches

### `derive_confidence`

(Already shown in §3.)

### Cascade scan helper (Phase C/D)

```python
def cascade_remove_doc_from_sources(doc_entity_id: str) -> dict:
    """Remove doc from all relations' sources. Recompute confidence.
    Drop relations whose sources became empty.
    
    Returns counts: {entities_touched, relations_recomputed, relations_dropped}."""
    counts = {"entities_touched": 0, "relations_recomputed": 0, "relations_dropped": 0}
    for entity_path in iter_all_entity_paths():
        fm = read_frontmatter(entity_path)
        relations = fm.get("relations", [])
        new_rels = []
        touched = False
        for rel in relations:
            old_n = len(rel.get("sources", []))
            rel["sources"] = [s for s in rel.get("sources", [])
                              if s.get("doc_id") != doc_entity_id]
            if len(rel["sources"]) == old_n:
                new_rels.append(rel)
                continue
            touched = True
            if not rel["sources"]:
                counts["relations_dropped"] += 1
                continue   # relation disappears
            rel["confidence"] = derive_confidence(rel["sources"])
            new_rels.append(rel)
            counts["relations_recomputed"] += 1
        if touched:
            counts["entities_touched"] += 1
            fm["relations"] = new_rels
            write_frontmatter(entity_path, fm)
    return counts
```

---

**End of memo.**

Update this file in place when phasing decisions or formula changes
are made — the design is the source of truth for the eventual
implementation PRs.
