Skip to content

Repository files navigation

PersonaCore

A from-scratch GPT that runs on a laptop CPU. Personalization is in the weights, not a database.

ci demo weights python pytorch on-device license

Quick start · Results · How it works · Evidence · Repository map · Corrections · Technical report · Notebook

Teach-then-recall demo: the same question asked with the persona adapter on and off, the memory ON turn answering with the taught dog name and the memory OFF turn not, while the token-id panel shows an identical prompt

One question, twice. The prompt token ids on the right are identical both times; only the 331,776 adapter parameters were switched off in between.

PersonaCore is a from-scratch, on-device research system for studying parametric personalization. It first demonstrated that synthetic profile values could be recalled from LoRA weights without prompt-side facts. A subsequent adversarial audit found that 88.5% of held-out questions were extractable under the strongest of four tested black-box attack families, against a no-adapter control of exactly 0/104 — and that selective erasure of a single fact destroyed 77.6% of the model's dialogue adaptation while failing to protect any of seven non-target facts. The project therefore studies both the capabilities and the privacy costs of weight-based memory.

Quick start

git clone https://github.com/RAFAELDCOELHO/PersonaCore.git
cd PersonaCore
make demo        # -> http://127.0.0.1:7860
What make demo does, and what it needs

make demo creates .venv if needed, installs the CPU Gradio extras, downloads checkpoints/model_slim.pt from the public m1-demo-v1 release when that file is absent (verifying its sha256 against the pin in scripts/fetch_demo_checkpoint.py before installing it), and launches the TinyStories story demo at http://127.0.0.1:7860. A second run does not re-download a present checkpoint. Story demo only: the teach-then-recall app needs locally produced checkpoints that are not in that release.

It needs Python 3.10 or 3.11 on PATH — the range pyproject.toml declares in requires-python. make demo picks the first candidate that reports a supported version rather than the first one named python3, so a box whose python3 is 3.12+ stops with an install hint instead of building a .venv that pip then refuses.

At a glance

Property Value
Model 13.9M-parameter GPT decoder: 6 layers, 6 heads, 384-dim embeddings, 256-token context
Memory mechanism From-scratch LoRA (rank 8, 331,776 trainable parameters) written into the weights; from-scratch EWC guards retention during dialogue fine-tuning
Runs on A laptop CPU at ~100 tok/s streaming, with zero network calls once installed
Trained on Apple Silicon (fp32 / MPS): zero external compute, zero budget
Built with Pure PyTorch, no HuggingFace model code anywhere in the runtime
Verified by 2,000+ CPU-only pytest tests, two CI jobs on every push, pre-registered gates for every headline number
Audited for Black-box extraction of taught facts and selective erasure, with the negative results published in full below

How it fits together

flowchart LR
    T["Byte-level BPE tokenizer<br/>(from scratch)"] --> B["GPT decoder, 13.9M params<br/>pretrained on TinyStories"]
    B -->|"dialogue fine-tune<br/>+ EWC anchor"| C["Conversational base"]
    C -->|"teach facts through<br/>rank-8 LoRA, base frozen"| P["Persona adapter<br/>331,776 params"]
    P --> D["Offline Gradio demo<br/>memory ON / OFF toggle"]
Loading

Repository map

Path What lives there
src/personacore/tokenizer/ Byte-level BPE from scratch: training, encode/decode, special tokens, schema-versioned freeze/reload
src/personacore/model/ The GPT-2-style decoder (pre-norm blocks, causal attention, weight tying) and the bigram baseline that proved the harness
src/personacore/training/ Hand-rolled loop, loss assembly (the EWC seam), warmup + cosine schedule, memmap data path
src/personacore/lora/ From-scratch LoRALinear, post-load injection, freeze discipline, key-audited adapter apply
src/personacore/continual/ From-scratch EWC: per-example diagonal Fisher and the Kirkpatrick quadratic penalty
src/personacore/generation/ The one shared generate(): greedy / temperature / top-k / top-p with EOS stop
src/personacore/dialogue/ Conversational data pipeline: stdlib episode parser, serialization, tokenizer-inflation metrics
src/personacore/privacy/ DP-SGD for LoRA gradients and a from-scratch (ε, δ) accountant (v4.0, in progress)
src/personacore/evaluation/ Deterministic full-corpus perplexity with an auditable denominator
scripts/ Thin entry points: pretraining, fine-tuning, teaching, the two demos, and every pre-registered phase gate
tests/ The CPU-only pytest suite, including the doc tests that lock the numbers on this page to their sources
results/ Committed evaluation artifacts: training curves, ablation cohort, A/B and audit reports, figures
docs/REPORT.md · demo.ipynb The technical report and the executed results notebook

Record of corrections

Recorded text in this repository is never rewritten in place: a finding that changes what an earlier sentence means is appended as a dated section, and the earlier sentence stays where it was. The sections below are that record, newest last.

Recorded Section What it records
2026-08-16 Claim correction — what the memory toggle demonstrates The demo's memory ON/OFF toggle is availability, not authorization: it withholds the adapter's contribution from one process and is neither an access-control boundary nor erasure
2026-08-19 v3.0 audit results The black-box audit measured the opposite of the low extraction rate it had anticipated (LEAKAGE_DEMONSTRATED), and the selective-erasure phase closed with verdict FAILURE and a DO NOT SHIP decision
2026-08-19 Pin defect labels — the phase publishes five, A through E A labelling correction: the closed erasure pin publishes five defects, lettered A through E, and the earlier "four" undercounts when read as a complete enumeration
2026-09-02 Repository status The test-suite figure is re-scoped by date, the two CI jobs and the digest-verified make demo path are named, and v4.0 is described as in progress

Results at a glance

  • Held-out recall 0.3483 (326/936) against a pre-registered gate of 0.2000, taught recall 0.4921 (496/1008) against 0.2486, and a closed-book control — the same weights with the adapter switched off — at exactly 0/2430; both thresholds came from a disjoint calibration fact set fixed before the run existed. The rate covers the proper-noun core only (the soft preference tier is excluded from the gate) and the held-out set deliberately omits reversed phrasings, so it demonstrates generalization within that scope and makes no claim about reversed recall — counts in results/phase14_recall_report.md, full form of each bound in docs/REPORT.md
  • Naive fine-tuning drove retention perplexity to 8.52417066884246; EWC held it to 3.8911400839446597 from the same step-0 anchor of 2.107553076833866 — drift +6.416618 vs +1.783587, a 3.6× difference, clearing the pre-registered margin at 33.61×. That is teacher-forced retention perplexity, not free-running story generation (both arms leak role tokens mid-story), and the noise floor the margin is measured against was not re-verified at the 4000-step production budget; EWC's acquisition cost of +0.380556 dialogue PPL is descriptive, with no gate — numbers in results/phase13_ab_report.md, full form of each bound in docs/REPORT.md
  • Dialogue costs 3.229 tokens/word through the frozen v1.0 tokenizer (4,800,385 utterance tokens over 1,486,754 whitespace words) against a TinyStories baseline of 2.860 recomputed in the same run with the same tokenizer and the same word rule — a 1.129× relative inflation, inside the pre-registered ≤1.2× GO band at a measured fit of 0.9996; the ratio is only meaningful against that same-run baseline and is never comparable to another tokenizer (results/inflation_report.md)
  • 13.9M parameters — 13,891,584 exact, tied embedding counted once (6 layers, 6 heads, 384-dim embeddings, 256-token context)
  • Deterministic full-validation perplexity 2.1066 over 12,636,922 scored target tokens (50k-step best.pt, computed by scripts/evaluate.py)
  • ~100 tok/s streaming on a laptop CPU (measured 95–105) — a complete ~200-token story in about 2 seconds
  • Trained on-device on Apple Silicon (fp32 / MPS) — zero external compute, zero budget
  • 100% from-scratch PyTorch — no HuggingFace model code anywhere in the runtime
  • Choices justified by ablation — weight tying and positional embeddings both earn their parameters; the full four-run cohort (with its honest reduced-budget caveat) is in docs/REPORT.md

Where the memory actually moved

Fisher diagonal beside the naive and EWC weight-delta grids, 6 layers by 6 projections

The naive and EWC delta panels share one color scale so the two arms are directly comparable; the Fisher panel has its own scale because squared-gradient importance is not a weight-delta ratio.

Measured rather than eyeballed: across those 36 cells the rank correlation between Fisher importance and how much EWC pulled the movement back is ρ = 0.801544 (95% CI [0.597984, 0.920291], rule and seed committed before the numbers existed) — a rank correlation, not an effect size, and EWC in fact moved further than naive in 2 of the 36 cells.

Relative weight change of the taught persona adapter across 6 layers and 6 projections

The persona adapter's own ‖ΔW‖_F/‖W₀‖_F grid, on an independent scale — it is not comparable to the panels above (different parameter counts, different training budgets), and the full reasoning is in docs/REPORT.md.

What is this?

Every component is hand-implemented in pure PyTorch:

  • Byte-level BPE tokenizer trained from scratch — vocab table 8192 with 547 ids live (256 bytes + 283 learned merges + 8 specials; the frozen production tokenizer artifacts/tokenizer.json, 5,648 bytes, was trained on the 11,469-byte fixture tests/fixtures/tiny_corpus.txtscripts/train_tokenizer.py:31 — and not on the full TinyStories corpus, which is why only 283 of the 7,928 requested merges were learned and the remaining 7,645 rows are reserved capacity), <|endoftext|> pinned as an atomic id, validated against a tiktoken oracle (test-only; a guard test proves the oracle is never imported by runtime code)
  • GPT-style decoder built by hand — pre-norm blocks, causal multi-head attention (masked before softmax), GELU MLP, weight tying as true shared storage
  • Hand-rolled training loop — AdamW, warmup + cosine LR schedule, gradient clipping/accumulation, resumable open-dict checkpoints that restore RNG state bit-for-bit
  • From-scratch LoRA adapters — rank-8 wrappers over the six named projections per block (q_proj, k_proj, v_proj, c_proj, fc_in, fc_out), 331,776 trainable parameters against a base proven bit-untouched, with runtime toggle / merge / eject
  • From-scratch EWC — per-example diagonal Fisher (N=2000) plus a Kirkpatrick quadratic anchor, spliced into the v1.0 loop through its assemble_loss(base, extra_penalties) seam with a bit-identical trajectory when the penalty is off
  • One shared generate() — greedy / temperature / top-k / top-p with EOS-stop, powering the tests, the notebook, and the demo identically
  • Per-component pytest suite — causality, weight-tying storage identity, init scaling, oracle equivalence, resume trajectories, adapter and Fisher invariants (~400 CPU-only tests)
  • Two offline Gradio demos — story completion, and the teach-then-recall demo with its live memory ON/OFF toggle; both on localhost with zero outbound network calls

Run the demo

After make demo has installed extras and downloaded weights, the demo itself makes zero network calls — it works with Wi-Fi off.

git clone https://github.com/RAFAELDCOELHO/PersonaCore.git
cd PersonaCore
make demo
# -> http://127.0.0.1:7860

Gradio chat demo streaming a TinyStories completion token-by-token on a laptop CPU

That is the default path. Optional fallback if you want to drive the steps by hand:

python3.11 -m venv .venv
source .venv/bin/activate
pip install -e ".[cpu,demo]" --extra-index-url https://download.pytorch.org/whl/cpu
mkdir -p checkpoints
curl -L -o checkpoints/model_slim.pt \
  https://github.com/RAFAELDCOELHO/PersonaCore/releases/download/m1-demo-v1/model_slim.pt
python scripts/demo_app.py

The artifact loads with torch.load(..., weights_only=True) — plain tensors and containers only, no code execution — and embeds its own ModelConfig plus the git SHA that produced it. If you have a local training checkpoint (best.pt) instead, regenerate the artifact with python scripts/export_slim.py.

The teach-then-recall demo (python scripts/personalize_demo.py) runs the same way but needs two locally produced checkpoints that are not in the m1-demo-v1 release: the conversational base (checkpoints/convbase_slim.pt) and the taught persona adapter (checkpoints/persona_adapter.pt, 1.35 MB), produced by scripts/finetune_dialog.py and scripts/teach_persona.py. Teaching happens in a different process from the demo, which is what makes the clean room true by construction rather than by assertion.

Evidence

  • docs/REPORT.md — the decision-driven technical deep dive: every load-bearing choice with its rationale and the test, ablation row, or training curve that validates it, plus the Milestone 2 results narrative and every honest negative quoted from its source report
  • demo.ipynb — the executed results notebook (rendered by GitHub): the model loaded from the slim artifact, exact parameter count, training curves, ablation plots, and a seeded sampling-settings tour
  • results/ — committed evaluation artifacts: training-curve CSVs, the ablation cohort table, qualitative samples (representative, not cherry-picked), the EWC A/B report, the recall report with per-question counts, and results/phase15_norms.json — the committed grid of weight-delta and Fisher norms both figures above are plotted from

Tests and reproducibility

make test    # full CPU-only suite — no GPU required

Reproducibility discipline: fixed seeds, the producing git SHA and full ModelConfig embedded in every checkpoint (including the shipped slim artifact), and resume that restores RNG state rather than re-seeding — an interrupted run continues its loss curve bit-for-bit.

Milestone 2 — what shipped

Milestone 1 deliberately built the sockets the thesis mechanism would plug into: six named nn.Linear projections per block for LoRA, an assemble_loss(base, extra_penalties) seam for EWC, and open-dict checkpoints for Fisher state. Milestone 2 plugged the thesis into them and measured what came out:

  • From-scratch LoRA adapters over those six projections — the weight-memory write mechanism, with a canary proving every trainable parameter moved and every frozen base parameter stayed bit-identical
  • From-scratch EWC through the assemble_loss seam — per-example diagonal Fisher and a quadratic anchor, both hand-written and pinned against analytic oracles
  • The unconfounded no-forgetting A/B — two 4000-step arms differing only in the penalty, with both axes reported (what each arm learned and what it destroyed) and the gate pre-registered in committed code before either run existed
  • The clean-room teach-then-recall demo — a live memory ON/OFF toggle over the same weights, a one-way Reset, and a panel showing the exact prompt token ids, so a reviewer can watch the answer change while the prompt does not

Honest next steps

  • The recall gate covers the proper-noun core; the soft preference tier and reversed phrasings were not measured as held-out properties
  • The retention result is teacher-forced perplexity; in free-running story mode both arms still leak role tokens, so qualitative retention is not claimed
  • The tokenizer stays frozen from Milestone 1 at 547 live ids — retraining it would invalidate every checkpoint here, so the cost of keeping it was measured instead
  • Every bound on every claim above is collected and quoted from its source report in docs/REPORT.md

Claim correction — what the memory toggle demonstrates (recorded 2026-08-16)

Appended, not edited. Everything above stands as written for v2.0. Recorded text in this project is corrected by dated note rather than in place, so no sentence above this line was changed to make this section true.

The v2.0 text above presents the demo's live memory ON/OFF toggle over the same weights. The mechanism it describes is accurate and is not amended: unchecking flips 36 boolean flags on the adapter wrappers, reloads nothing, recomputes nothing, and leaves the prompt token panel identical between the two states. What the toggle demonstrates is narrower than a switch labelled ON/OFF invites a reader to assume.

The memory toggle is availability, not authorization: unchecking withholds the adapter's contribution from this process, it revokes no one's access to the weights and puts nothing beyond recovery.

Withholding a contribution is neither an access-control boundary nor erasure. With the box unchecked, everything the adapter learned is still present in its 331,776 parameters, the 1.35 MB file on disk is untouched, and anyone holding that file has white-box access — strictly more than the checkbox ever gave away. The one-way Reset is a different mechanism with a different claim and is outside this correction.

Phase 18 is the black-box audit measuring how much of the adapter's contents a prompt-only attacker can recover, and its caveat is recorded here before its numbers exist: a low extraction rate may be a property of LoRA at this capacity — 331,776 trainable parameters adapting a 13.9M-parameter base — rather than an achievement of PersonaCore's design, and that audit runs no arm separating the two. The pinned wording is LORA_PROPERTY_CAVEAT in scripts/phase18_extraction.py; the measured result and its interval are appended to docs/REPORT.md through the same additive path when the run completes.

v3.0 audit results (recorded 2026-08-19)

Appended, not edited. Everything above stands as written, including the anticipatory paragraph that closes the section above this line. Recorded text in this project is corrected by dated note rather than in place, so no sentence above was changed to make this section true.

The audit ran, and it measured the opposite of a low rate. The paragraph above anticipates a low extraction rate and records its caveat before the numbers existed. The numbers now exist: across the best attack family (A2) on the adapter-on arm, 92 of 104 core_held_out questions were extracted at least once — 88.5% (rate 0.884615), with a one-sided 95% Wilson lower bound of 0.8231, against an adapter-off control arm — the same weights with the adapter switched off — at exactly 0/104 questions at identical budget. The unit is the question: a question counts once if any of its 48 draws contained the full value. The verdict is LEAKAGE_DEMONSTRATED, returned by the pre-registered gate and published as such in results/phase18_extraction_report.md.

Black-box prompt access is the weakest threat model available here, so that number is a floor on leakage, never a ceiling on privacy: anyone holding the 1.35 MB adapter file has white-box access — gradients, per-token probabilities, direct parameter inspection — which is strictly more powerful than anything the audit ran.

The LoRA-capacity caveat still stands, re-scoped. LORA_PROPERTY_CAVEAT is not withdrawn: this audit still runs no arm separating a capacity property of LoRA from an achievement of PersonaCore's design, and that gap is real. What the caveat no longer does is explain a low number, because there was never a low number to explain. It was recorded to stop a comfortable result from reading as an achievement; the result was not comfortable.

Phase 19 — selective erasure attempted, verdict FAILURE, ship decision DO NOT SHIP. Phase 19 tried to erase exactly one taught fact from the adapter and leave the rest intact. It could not: the erasure was not localised to the fact, all seven non-target facts degraded past the pre-registered margin, and 77.6370113463966% of the dialogue adaptation was destroyed. The phase is closed and honest, not blocked. DO NOT SHIP withholds exactly one claim, and it is the sole reason — that the FAILURE verdict is mechanically reproducible by the pinned script alone. It is not: the verdict was reached on a hand-driven path around four published defects in that pin. Nothing is withdrawn — the verdict, every measurement behind it and all four defects with their dated corrections stand exactly as published, in results/phase19_erasure_report.md and docs/REPORT.md.

Pin defect labels — the phase publishes five, A through E (recorded 2026-08-19)

Appended, not edited. Everything above stands as written, including the Phase 19 paragraph that closes the section above this line. Recorded text in this project is corrected by dated note rather than in place, so no sentence above was changed to make this section true. This is a labelling correction and it retracts nothing: no defect is added, none is withdrawn, no measurement moves.

Phase 19 publishes five distinct defects in the closed pin scripts/phase19_erasure.py, and the canonical labels are LETTERS. They are fixed in results/phase19_erasure_report.md, section Defect numbering — the canonical labels, which moved to letters because two different defects had each been published as "the fourth" and no ordinal identified either one unambiguously.

label defect line in the closed pin
A zero_results_have_nll compares an ORDERED tuple against records serialised with sort_keys=True, so it reads False on KEY ORDER ALONE while every NLL is present :1562 vs :2948
B _calibration_rate() reads record["pre_erasure"]["per_fact"] — Phase 18's candidate recall — rather than the calibration arm's own rate :3850-3855
C rows.update(per_fact_rows(...)) lets one (b) tier overwrite the other, and the pinned report subcommand SystemExits on the resulting rows :2922
D _cmd_report passes retention_perplexity's [ppl, n] pair straight into the gate's scalar retention_ppl=, where the comparison raises TypeError :3811
E _selected_components reads the TARGET's stopping rule on the calibration twin's 6 members while reading every BYSTANDER on 8, inside one call :3576

A, B and C are published in results/phase19_calibration_correction.json as the record keys defects.A, defects.B and defects.C; D in results/phase19_erasure_report.md; E in results/phase19_reference_set_correction.md.

Four versus five — the distinction, stated rather than left to a reader. A, B, C and D are the four independent ways the pin's own _cmd_report cannot reproduce the FAILURE verdict, and those four are what the ship decision enumerates. E sits in the erase subcommand — a path that the render never called — so it cannot be one of the four ways the pinned report path fails, and the phase nonetheless publishes five. Both counts are correct about different things, which is exactly why the letters exist.

What that makes of the phrasing above. The section above records that the verdict "was reached on a hand-driven path around four published defects in that pin", and that "all four defects with their dated corrections stand exactly as published". Both sentences are correct about the four _cmd_report failures — A, B, C and D — and both UNDERCOUNT the phase when read as a complete enumeration of the defects it published, which is how they read here: unlike the erasure report, README grants itself no in-file exemption. The earlier phrasing is left standing rather than edited, per this project's dated-continuation discipline — an in-place correction would also erase the evidence that the miscount was ever published.

Nothing moves. The verdict of record is still FAILURE, the ship decision of record is still DO NOT SHIP and still withholds exactly one claim, no defect is added or withdrawn, and no number above changes.

Repository status (recorded 2026-09-02)

Appended, not edited. Everything above stands as written. The "~400 CPU-only tests" figure under What is this? was true when v1.0 shipped and is left standing; as of this note the suite is 2013 passed, 1 skipped on the M3 (full run, 21 minutes, 2026-09-02), and CI runs two jobs on every push: test (the same suite on ubuntu-latest with the CPU-only torch wheel) and demo-asset, which downloads the public m1-demo-v1 model_slim.pt over the anonymous release URL and verifies it against the sha256 pinned in scripts/fetch_demo_checkpoint.py — a download that does not hash to the pin is refused, never installed. make demo (PRs #2 and #4) is the one-command public path. The repository carries an MIT LICENSE and a CITATION.cff.

v4.0 — Leakage Mitigation and Relearning Validation — is in progress: Phase 25's frontier sweep (44 points, DP-SGD and adversarial arms at two adapter capacities) is built and calibrated and waits at an operator checkpoint before its 88–150 hour M3 run. No adapter from that sweep exists yet, so no number above changes.

License and citation

PersonaCore is released under the MIT License. To cite it, use GitHub's Cite this repository button (backed by CITATION.cff) or:

@software{coelho2026personacore,
  author  = {Coelho, Rafael D.},
  title   = {PersonaCore},
  year    = {2026},
  url     = {https://github.com/RAFAELDCOELHO/PersonaCore},
  license = {MIT}
}

About

From-scratch, on-device GPT with weight-based personalization — an adversarial privacy audit measuring what LoRA memory reveals under black-box attack and what selective erasure can (and cannot) undo. Run with `make demo`.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages