A careful evidence model, and two tools that route around it

Inno Agent

A personal learning agent whose L1 learner profile derives mastery from weighted, typed evidence — recall quality, hint level, spacing, evaluator confidence — and whose misconception status genuinely gates what reaches the prompt, beside two profile tools that write mastery, a diagnosis and a list of evidence ids nothing resolves against the event log.

Carries 3 of 7 rubric mechanisms. Most systems here carry none or one (41%), and a dash means the mechanism was not found at this commit — not that the system needed it. Each mark is one LLM reviewer's reading of the code at this commit rather than a run of it — known limits.

  • Tombstone
  • Trust state
  • Bi-temporal
  • Scope enforced
  • Mutation audit
  • Human review
  • Negative evals

1. Executive Summary

Inno Agent is a personal learning agent — MIT, TypeScript, 84,072 lines across 90 test files, built on the Pi coding-agent SDK with the kernel deliberately unmodified. It ships as an Electron app, a React web UI and a terminal CLI over one runtime state, and it organises memory into three declared layers: an L1 learner profile, an L2 Markdown wiki, and L3 session records with cross-conversation recall.

The README states the stance plainly: "Durable facts go to tools, not replies — anything that affects future teaching is written to L1/L2 via tools, so personalization is evidence-driven and traceable," and "unevidenced labels are forbidden."

The evidence model behind that claim is the best-argued one in this family. evidence.ts types a piece of evidence by kind and weights it: exposure is worth zero, recognition 0.25, guided_recall 0.45, free_recall 0.75, application 0.85, transfer 1, and a learner's self_report 0.1. That is multiplied by a hint-level factor that drops to 0.1 at three hints, by the evaluator's confidence, and by a spacing factor that pays 1.25 for a week-delayed success and 0.75 for one inside five minutes. Mastery moves by LEARNING_RATE * weight * (observed - mastery), so being shown something moves nothing at all.

The guard on top of it is the kind of detail that only gets written by someone who has watched a model produce contradictory output. resultValue clamps the optional numeric score into the band its categorical result allows, with the reason in the comment: "A model can accidentally emit contradictory fields (for example, result=incorrect with score=1); constrain the optional score to the selected result band so malformed evidence can never invert the learning signal."

Then there are two other tools.

patch_learner_profile takes an absolute mastery, a free-text diagnosis, and evidence_ids_append. update_learner_profile takes whole knowledge-state objects, including estimate_confidence, stability_days, successful_transfer_count and an optional state_label. Both call saveProfile directly. Neither appends anything to events.jsonl. And nothing anywhere resolves an id in evidence_ids against the event log.

That matters more than it looks, because evidence_ids is doing three jobs at once. It is the provenance list shown to the learner. It is a confidence gate — state-engine.ts:111 reads a non-empty array as licence to raise the estimate-confidence ceiling from 0.35 to 0.6. And it is the dedup key: state-engine.ts:123 builds a Set from it, and any evidence whose id is already in that set is skipped outright. The array is also not a coherent key space — auto-profile.ts:273 pushes event.event_id into it while state-engine.ts:267 pushes evidence.evidence_id, two different id namespaces in one field.

So the careful path cannot be faked and the other path does not need to be.

2. Mental Model

Evidence is the atom. It has a kind, a categorical result, a hint level, an evaluator and that evaluator's confidence. It is appended to events.jsonl and never edited.

A knowledge state is not stored truth. projectKnowledgeState takes the stored state as a prior and replays evidence over it, producing a DerivedKnowledgeState with a state_label of unknown, learning, fragile, review_due, stable or misconception. The label is recomputed on every read; the stored state_label field is written by tools and never read back.

A misconception is the one genuinely stored status. active blocks teaching. Leaving active requires a linked, hint-free, confidently evaluated correct retrieval.

The wiki is Markdown with frontmatter. Its pages also carry a status — draft, reviewed, outdated — and that one is decorative.

Diagram — evidence reaches the projection through a weighted, guarded path that cannot be faked; the two profile tools write the prior that projection starts from, and skip the event log entirely
Diagram source
%% caption: evidence reaches the projection through a weighted, guarded path that cannot be faked; the two profile tools write the prior that projection starts from, and skip the event log entirely
flowchart TB
    subgraph EV["the evidence path"]
        T1["record_learning_evidence"] --> LOG[("events.jsonl<br/>append-only")]
        LOG --> W["evidenceWeight =<br/>kind × hint × evaluator_confidence × spacing<br/>exposure = 0, transfer = 1"]
        W --> RV["resultValue clamps score<br/>into the result's band —<br/>'malformed evidence can never<br/>invert the learning signal'"]
    end
    subgraph TOOLS["the two other tools"]
        P["patch_learner_profile<br/>absolute mastery, free-text diagnosis,<br/>evidence_ids_append"] --> SAVE["saveProfile —<br/>writes profile.json"]
        U["update_learner_profile<br/>whole KnowledgeState objects:<br/>estimate_confidence, stability_days,<br/>successful_transfer_count"] --> SAVE
        SAVE -.->|"appends nothing"| LOG
    end
    SAVE --> BASE["stored KnowledgeState<br/>= the PRIOR"]
    BASE --> PROJ["projectKnowledgeState"]
    RV --> PROJ
    IDS["evidence_ids — never resolved<br/>against events.jsonl"] --> GATE1["non-empty ⇒ confidence<br/>ceiling 0.35 → 0.6"]
    IDS --> GATE2["used as the dedup Set:<br/>matching evidence is SKIPPED"]
    BASE --> IDS
    GATE1 --> PROJ
    GATE2 --> PROJ
    PROJ --> LABEL["state_label recomputed<br/>every read"]
    MIS["misconception.status<br/>active | repairing | resolved | stale"] --> ACT{"status === 'active'?"}
    ACT -->|"yes"| BLOCK["blocks teaching:<br/>enters the context pack,<br/>forces label = misconception"]
    ACT -->|"repairing / resolved / stale"| PASS["stops blocking —<br/>the ladder has four rungs,<br/>the filter tests one"]
    HUMAN["learner's web panel:<br/>PATCH mastery, diagnosis,<br/>misconception status"] --> BASE
    HUMAN -.->|"status cast with no<br/>enum validation"| MIS
    LABEL --> PACK["context pack → the prompt"]
    BLOCK --> PACK

3. Architecture

Area Role
memory/learner/evidence.ts Evidence typing and the weight function
memory/learner/state-engine.ts The projection, the misconception gate, the FSRS-style stability model
memory/learner/profile-store.ts profile.json, the append-only events.jsonl, and its rotation
memory/learner/profile-updater.ts patchProfile and updateProfile — the direct write paths
memory/learner/context-pack.ts What actually reaches the prompt
memory/learner/prerequisite-resolver.ts Prerequisite assessment feeding the teaching gate
memory/learner/teaching-entry-gate.ts Diagnose, teach, repair or answer directly
memory/l2/ The wiki: ingest, chunking, summarising, linking, linting, graph and overview
memory/l3/ Session indexing into SQLite FTS5, and recall
server/routes/learner.ts The learner-facing edit API

4. Essential Implementation Paths

Read evidence.ts and state-engine.ts:100-200 first — that is the argument the project is making, and it is worth the time regardless of what you conclude about the rest.

Then read profile-updater.ts:117-182 immediately afterwards. The contrast is the whole report.

Then state-engine.ts:250-281 for the misconception gate, which is the part that got it right.

5. Memory Data Model

L1 is one JSON file plus one JSONL log. The profile holds goals, knowledge states, misconceptions, preferences and a summary; the log holds learning events, each optionally carrying a structured evidence object.

A stored KnowledgeState and a computed DerivedKnowledgeState are different types with overlapping fields. The stored one carries mastery, confidence and stability on a 0–1 scale; the derived one carries mastery, estimate_confidence, stability_days, retrievability, next_review_at, counts of exposures, retrievals, lapses and successful transfers, and the state_label. The tool schema exposes the derived fields as optional inputs on the stored type, which is how a caller ends up able to submit successful_transfer_count directly — one of the four conditions for the stable label.

L2 is a Markdown tree. A page's frontmatter carries status, confidence, sources, source_ids, an optional concept_id linking it to L1's concept space, explicit prerequisites with a relation and a source, and optional contested and contradictions fields. The distinction between a typed prerequisite and a plain wikilink is drawn deliberately: "Explicit teaching dependencies. Ordinary wikilinks remain non-directional relatedness."

L3 is chunks and chunks_fts in SQLite, one row per user or assistant turn.

6. Retrieval Mechanics

L1 is not retrieved. buildContextPack projects every concept, sorts by a fixed state priority — misconception, review_due, unknown, learning, fragile, stable — takes five, collects the active misconceptions, and renders them as lines in the prompt. There is no query and no threshold, which for a single-learner profile is the right call.

L2 search is keyword matching over manifest fields, falling back to page body content, with an embedding-backed path when configured.

L3 is FTS5 with an excludeSessionId option so cross-session recall does not return the conversation you are already in — a small thing that many systems in this corpus get wrong.

The retrieval finding is what isn't filtered. WikiPageStatus is draft | reviewed | outdated, and the only place a status value is compared in L2 is l2-tools.ts:182, where status === "indexed" on a manifest entry guards re-archiving. No read path tests a page's review status: wiki-page-model-view.ts:161 normalises it for rendering and that is the end of it. An outdated page is retrieved and presented exactly like a reviewed one. contested is the same — the maintainer counts contested pages into the overview for a human to read, and nothing withholds them.

Two statuses in one system, and only the L1 one is enforced.

7. Write Mechanics

record_learning_evidence builds a structured evidence object, appends it, and folds the deterministic signals into the profile — with a dedupe_key check that scans the existing log before appending.

The two profile tools are the finding. patchProfile creates the knowledge state if absent, then applies whatever the caller sent: mastery absolute or by delta, confidence, a stability_delta, a diagnosis string, appended next_actions, appended evidence_ids, last_practiced_at and review_due_at. updateProfile merges whole objects by id. Both end at saveProfile, which bumps a version counter and writes the file.

Nothing validates that an appended evidence id exists. Nothing records that the write happened. The version counter increments, and it is the only trace.

The consequence is not that mastery can be set — a personal agent has every right to let a learner assert what they know. It is that an asserted value is stored in the same field, with the same shape, as a derived one, and the provenance field that would tell them apart is itself free text. The projection then treats the asserted value as a prior and moves it by 35% of one weighted step per piece of real evidence, so a fabricated 0.95 survives several honest failures.

The web panel writes through a second door with a different validation posture. The tool path declares status as a StringEnum of four values; the HTTP handler at learner.ts:173 does (body.status as Misconception["status"]) ?? current.status — a bare TypeScript cast, which erases at runtime. A PATCH carrying any string stores it, and since the read path tests only status === "active", an unrecognised value silently stops the misconception from blocking.

8. Agent Integration

The SDK kernel is not modified; memory is registered tools plus one extension hook, and the README makes that a stated design constraint. That is the right instinct for staying upstream-compatible, and it is visible in the code rather than only in the prose.

formatTeachingEntryDecision is worth reading as an artifact in its own right: when the gate decides to diagnose, it emits a protocol block instructing the next reply to ask exactly one observable question, give no hints or answers, and stop and wait. It is prompt engineering, not enforcement — nothing checks that the model complied — but the decision of what to constrain is well made.

9. Reliability, Safety, and Trust

The misconception gate is the system's best mechanism and deserves the credit. Its comment states the rule and the code keeps it: a correct answer on the concept does not clear a misconception; only an evidence record explicitly carrying that misconception_id, produced by a retrieval-kind interaction, at hint level 0 or 1, with an evaluator at least 0.7 confident and a computed weight of at least 0.25, moves it out of active. A later linked failure sets it straight back.

The gap is at the other end. That careful transition moves active to repairing — never to resolved, which no code path sets. And both read filters test status === "active". So the moment a misconception becomes repairing it stops entering the context pack and stops forcing the misconception state label, even though repairing plainly means the repair is underway rather than done. A four-value vocabulary is being consumed as a boolean, and the value the careful gate produces is on the permissive side of it.

events.jsonl is append-only and rotates at 10 MB, and loadEvents — which rebuildProfileFromEvents uses — reads only the current segment. The code says so: "older segments stay on disk as archives." A rebuild after a rotation therefore replays a truncated history. rebuildProfileFromEvents also starts from the current profile rather than a default, so it is a replay-on-top, not a reconstruction; a value written by patch_learner_profile survives it untouched.

There is no scope key anywhere, which is a stated non-goal rather than an oversight — the README lists multi-user support under "Non-goals" and the architecture is consistent with that. Workspaces are separated by data directory only.

10. Tests, Evals, and Benchmarks

Ninety test files, and the memory ones are real. l3.test.ts seeds a session whose assistant turn carries a thinking block and a visible text block, then asserts the chunk count is 2, the visible text is findable, and the reasoning is not — a negative with its positive control and an exact count in the same test, against the real store. state-engine.test.ts:216-254 is titled "moves only an explicitly linked, reliably repaired misconception out of active" and asserts the status lands on repairing after the good repair and returns to active after a later linked failure — which is also the clearest confirmation that repairing, not resolved, is what the careful path produces. wiki-linker.test.ts asserts a hallucinated page name does not end up in a page body and that the graph reports no missing targets. server.smoke.test.ts drives the learner API end to end over a real HTTP server, including the delete.

What is not tested is the seam this report is about: no test writes a knowledge state through patch_learner_profile and then asserts anything about how the projection treats it, and no test appends a fabricated evidence id and checks what happens to the confidence ceiling or the dedup set.

11. For Your Own Build

Take evidence.ts more or less whole. Typing evidence by what the learner actually did — recognised, recalled with hints, recalled freely, applied, transferred — and weighting exposure at exactly zero is the single most useful idea here, and it generalises past education to any memory that records how strongly something was confirmed.

Take resultValue's guard, and take the reason with it. When a model supplies both a category and a number, let the category constrain the number's range. The comment naming the failure it prevents is what makes the guard survive the next refactor.

Take the misconception rule: a blocker is not cleared by success on the general topic, only by a check explicitly linked to the blocker itself, and a later linked failure reinstates it.

Do not let a field be provenance, a confidence gate and a dedup key at once. evidence_ids is all three, and because the first job is descriptive nobody validates it, while the second and third are load-bearing. Split them: a provenance list can be free-form, but anything that raises a confidence or suppresses a record must be resolved against the store that would confirm it.

If you have a derived value and an asserted one, do not store them in the same field. Keep the assertion beside the derivation with its own provenance, and decide explicitly which one the read path uses.

Make the status vocabulary and the read filter agree. Four values and one === "active" check is a decision about repairing that nobody wrote down.

12. Open Questions

Whether resolved and stale are intended to be reachable. No code path sets either; both are in the type, the tool schema and the web PATCH. If the model is expected to set them through update_learner_profile, the careful gate in applyEvidenceToLinkedMisconception can be bypassed by the same call.

Whether the stored state_label is meant to be authoritative anywhere. It is writable through the tool schema and the projection never reads it.

Whether the technical report — docs/inno-agent.pdf, described in the README as an arXiv paper from June 2026 — describes the evidence model as implemented or as designed. It was not read here.

Appendix: File Index

Path What to read it for
apps/inno-agent/src/memory/learner/evidence.ts The weighting, and why exposure is worth zero
apps/inno-agent/src/memory/learner/state-engine.ts The projection, the dedup set, and the misconception gate
apps/inno-agent/src/memory/learner/profile-updater.ts The two write paths that skip the log
apps/inno-agent/src/memory/learner/context-pack.ts The status === "active" filter
apps/inno-agent/src/server/routes/learner.ts The human edit surface, and the unchecked cast
apps/inno-agent/src/memory/l3/l3.test.ts Thinking blocks excluded, asserted both ways
apps/inno-agent/src/memory/l2/types.ts A page status nothing filters on

History

2026-09-16fdd95ccd… — first reading, at a commit dated 16 September 2026. Screened before opening, from a shallow clone: ten files scanned, no auto-run surfaces, five dependency files inside the seven-day cooldown, six unpinned surfaces including two package.json files with no lockfile beside them and a xlsx dependency from a vendored tarball, and the CLAUDE.md read as data. Nothing was installed, built or run.