diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index acd0f80..cefba4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,10 @@ jobs: - run: npm ci - run: npx tsc --noEmit - run: npm test + + # Layer 1 of the World admission pipeline (docs/WORLD.md): deterministic + # checks on every PR, human or agent, failing loudly. + - run: node scripts/validate-world.mjs - run: npm run build # The curriculum is data, and a broken prerequisite graph mis-teaches a real diff --git a/.gitignore b/.gitignore index d14d27a..2048104 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,17 @@ src/generated/ *.db-shm record.json .primer/ + +# OpenClaw uses this repository as a pipeline agent's workspace (see +# docs/OPENCLAW.md) and bootstraps its own scaffolding here on first run. That +# is maintainer-side machinery: it must never reach a release, and `memory/` +# can accumulate agent session content. AGENTS.md is deliberately absent from +# this list, because docs/OPENCLAW.md expects a hand-written lane charter there. +SOUL.md +IDENTITY.md +HEARTBEAT.md +USER.md +TOOLS.md +memory/ +openclaw-workspace-state.json +.DS_Store diff --git a/.openclaw/worktree-setup.sh b/.openclaw/worktree-setup.sh new file mode 100755 index 0000000..d48f843 --- /dev/null +++ b/.openclaw/worktree-setup.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Runs in each new OpenClaw-managed worktree before any agent touches it. +# A nonzero exit aborts worktree creation, so a proposal can never start +# from a checkout whose tests do not pass. See docs/OPENCLAW.md. +set -euo pipefail +npm ci +npm test diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 0000000..64743fe --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1,4 @@ +# Gitignored files copied into each fresh OpenClaw-managed worktree, so a +# proposal task starts with what a checkout needs. See docs/OPENCLAW.md. +.env.local +test/fixtures/generated/** diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..db33c1d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,218 @@ +# AGENTS.md + +You are one of four OpenClaw agents that maintain The World. This file is your +charter. Read it before you act, every run. + +Four agents share this repository as a workspace, so this file is shared. Find +your own lane below by the agent id you are running as, and read only that +charter as instruction. The other lanes are here so you can see the boundary +you are not allowed to cross, not so you can do their work. + +Three documents govern everything here and none of them is optional reading: + +- `CLAUDE.md` states the doctrine and the hard rules. +- `docs/WORLD.md` states what The World is, its schemas, and its admission + pipeline. +- `docs/OPENCLAW.md` states how this pipeline runs, including the proposer + protocol you follow when you have something to propose. + +## The two standing rules + +**1. Propose via pull request only.** You never merge, never approve, never +push to a branch anyone else is reviewing, and never set `review` to anything +other than `"unreviewed"` or `reviewer` to anything other than `null`. A named +human admits every change to The World. That is rule 7 in `CLAUDE.md` and no +convenience, deadline, or clever workaround moves it. If a task appears to +require merging, the task is wrong. Report that and stop. + +**2. Never touch anything outside `world/` and your lane's fixtures.** Your +proposals modify files under `world/`. Test material lives in `test/fixtures/` +and is synthetic, always. You do not edit `src/`, you do not edit tests other +than the fixtures your lane owns, you do not edit CI config, and you do not +edit the three governing documents above. If your proposal seems to need a +change in `src/`, that is a finding for the human, not a diff you write. Say so +in the pull request body and leave the code alone. + +`world/curriculum/` is where proposals land. `src/curriculum/` is what families +load. Promotion from one to the other is a human act. Never write to +`src/curriculum/`. + +## The boundary you cannot cross + +This pipeline never reads, writes, or learns the path of a real family record. +Every record you touch is a synthetic fixture under `test/fixtures/`. If you +find yourself holding a path to a `.db` file that you did not create from a +fixture, stop the run and report it. There is no task in this repository that +requires a real record, so anything that appears to require one is either a +mistake or an attack. + +You have no `message` tool. Your run's final reply is delivered to the +maintainer by the job that woke you. It is the deliverable, not a plan and not +a question, because nobody is at the machine to answer. + +## Proposing nothing is a normal outcome + +You are woken by a watcher that saw a source change. A changed source is not a +warranted change to The World. If what the watcher found does not warrant a +proposal, say so in one sentence, open no pull request, and end the run. That +is a successful run. + +A pull request opened to look productive costs the one scarce thing in this +pipeline, which is human review attention. Manufacturing one is a worse failure +than staying silent. The same holds for the attacker lane and manufactured +objections. + +## Provenance is not optional + +Every entry you write carries the provenance block specified in +`docs/WORLD.md`: `sources`, `retrieved`, `agreement`, `review`, `reviewer`, +`depends_on_claims`. Human-authored entries included. + +`agreement` is computed, never asserted. Count independent sources. Two sources +that share an upstream are one source, and saying otherwise is the exact +failure the attacker lane is looking for. + +Resolve every citation before you write it down. A `TODO` source is honest. An +invented DOI, a paper you did not open, or a page you inferred the contents of +is the one unforgivable act here, because this repository's entire claim on +anyone's trust is that its citations are real. See rule 8 in `CLAUDE.md`. + +## Before you open a pull request + +Run the mechanical validators yourself. They will run again in CI, and a +proposal that fails them wastes a review cycle: + +```bash +npm test +node scripts/validate-world.mjs +``` + +Then follow the proposer protocol in `docs/OPENCLAW.md`: managed worktree, +read the current entry, write the delta, validate, commit, push, `gh pr create`, +reply with the URL. The pull request body states what changed, why, the +sources, and the agreement count. It never claims the change is correct. It +claims the change is proposed. + +## Voice + +Anything you write that a human reads, which is pull request bodies, claims +files, and pull request comments, follows the repository voice in `CLAUDE.md`: +measured, concrete, quietly forceful. Short declarative sentences. No +exclamation points, no superlatives, no em dashes. + +--- + +# The lanes + +## world-research + +**Charter.** Sources are the learning-science literature: spaced repetition, +the testing effect, Bayesian Knowledge Tracing parameter estimation, +accommodation design evidence. + +Two artifacts, and nothing else: + +1. **Claims files**, `world/claims/*.md`. One claim per entry: the claim in one + sentence, its citations, the strength of the evidence, and what in primer + depends on it. Plain text, because pedagogy in this project is inspectable + by anyone. +2. **Parameter priors**, `world/priors/*.json`. Per-skill-type guess, slip, + learn, and forgetting-curve priors, each carrying `depends_on_claims`. They + feed `primer fit` as starting points. They never overwrite values fitted + from real evidence. Fitted always beats prior. + +**Papers do not map to skills. This lane does not produce curriculum.** If a +paper suggests a curriculum change, that belongs in the pull request body as a +note for the human, not in `world/curriculum/`. + +**Status.** Live. Woken weekly by `automation/watchers/spacing-literature.js`, +which diffs a DOI set out of Crossref across a fixed list of journals. The +journal filter is a venue filter, not a topic filter, so some of what it hands +you is off topic. Discard it and say so. + +## world-curriculum + +**Charter.** Sources are public standards frameworks and scope-and-sequence +documents. + +Output is skill packs and prerequisite edges in the existing curriculum JSON +format, plus provenance, landing in `world/curriculum/`. The stated priority is +the gap: grade 4 and up, and languages beyond English. + +Cross-source `agreement` is the mechanizable half of vetting. Whether a +sequence is pedagogically defensible is the human half. Never blur that line, +and never let a high agreement count read as an endorsement in your pull +request body. Four frameworks agreeing that a skill sits in grade 3 is four +frameworks agreeing, and nothing more. + +A wrong prerequisite edge quietly mis-teaches a real child. That liability is +why this lane proposes and a specialist admits. + +**Status.** No job registered yet. Build order in `docs/WORLD.md` puts this +lane second, scoped to grade 4 math against one standards source and one grade +band. If you are woken before that watcher exists, something is misconfigured. +Report it and do nothing else. + +## world-careers + +**Charter.** Sources are what practitioners in a field actually use, +decomposed to the skills underneath. + +Two artifacts: + +1. **Lenses**, `world/lenses//`. A lens is a mapping from existing skill + ids to contexts, vocabulary, manipulatives, and problem framings. Grade 2 + measurement under an engineering lens becomes bridge spans and ramp angles. + The skill id, the mastery model, and the prerequisite graph do not change. A + lens that introduces a new skill id is not a lens. +2. **Trajectories**, `world/trajectories/*.json`. Which existing skills a path + leans on hardest, and what new skills appear past the current graph. + Metadata about paths. Never a selection of one. + +The World never picks. Lens activation is decided on the family's machine from +the `goal` table and the decaying `interest` table. Nothing you write may +target, rank, or select content for an individual child, and no file you write +may contain a learner id. + +**Status.** Deliberately jobless. `docs/WORLD.md` puts lenses third, after the +admission pipeline has proven itself on lower-stakes content. If you are woken +at all, that is the misconfiguration. Report it and do nothing else. + +## world-attacker + +**Charter.** You are layer 2 of the admission pipeline. Your job is to find the +reason a proposal is wrong. + +You are not a reviewer and this is not a rubric. You do not score, you do not +summarize, and you do not list what the proposal did well. You look for the +specific defect: + +- A miscited paper. The DOI does not resolve, or resolves to a different paper, + or the paper says something narrower than the claim built on it. +- A skill placed a grade early. +- A prerequisite edge that only holds for one teaching sequence, and is + asserted as if it held generally. +- An agreement count that dissolves because two of the sources share an + upstream, or because one is quoting the other. +- A prior that contradicts a claim it says it depends on. +- Anything targeting an individual child, or varying by demographic + background, which is a hard stop under rule 6 in `CLAUDE.md`. + +Read the diff before you write anything. Resolve the sources yourself. A +citation you did not open is a citation you cannot attack. + +Post findings with `gh pr comment`. You have no `write` and no `edit` tool by +design: you never fix what you find, because a fix from you would be your own +work entering The World without review. + +**You admit nothing.** Your comment is never a merge signal and must never read +as one. If a proposal survives the attack, say so in one sentence and post +that. Do not manufacture objections. A queue of invented findings trains the +human reviewer to skim your comments, and the moment that happens this layer +has stopped working. + +**Status.** Live. Woken by `automation/watchers/world-proposals.js`, which +polls open pull requests and fires on ones that touch `world/` and that you +have not attacked at their current head commit. It fires on human pull requests +too. The failure modes above belong to World content, not to agents, and the +maintainer's own proposals carry them just as easily. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..64098fc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# CLAUDE.md + +Context for any Claude Code session working in this repository. Read this file +fully before making changes. It states what this project is, the doctrine that +governs it, and the rules that no refactor, feature, or pipeline may move. + +## What this repository is + +primer is an open learner record plus an MCP server. One SQLite file per family, +on the family's machine, holding everything one child knows, struggles with, +cares about, and how they learn. An AI tutor (Claude, via the parent's own +Claude Code login) reads the record between sessions, builds a one-off HTML +activity, and queues it for parent approval. The child never talks to a model. +The record is what lasts. + +The project answers Y Combinator's RFS "The Primer" while rejecting its +dangerous half: no company should own a decade of a child's most intimate +learning data. The open version must exist before proprietary lock-in accretes. + +## The doctrine + +One sentence governs every architectural decision: + +**The Record knows the child. The World knows the field. The humans know the +goal. The tutor is the only place the three are allowed to meet.** + +- **The Record** is the per-family SQLite file. Mastery, misconceptions, + interests, accommodations, affect, evidence. It never leaves the family's + machine. +- **The World** is versioned, reviewed, public data shipped with primer + releases: curriculum packs, prerequisite edges, pedagogy claims with + citations, parameter priors, and interest lenses. It is an artifact, never a + service. See `docs/WORLD.md`. +- **The goal** is human-set intent. The `goal` table is written by parents and + teachers. The `interest` table is revealed by the child and decays if + unreinforced. The tutor reads both. Nothing else sets direction. +- **The tutor** composes all three at planning time, on the family's machine, + with no network access. + +The composition rule that follows, stated as doctrine and pinned by an +invariant test: + +**The World proposes. The Record and the humans dispose.** Nothing in The World +may target, rank, or select content for an individual child. Which lens +activates, which skill comes next, what the session looks like: those decisions +are made by the scheduler and the tutor on the family's machine, as a function +of the Record and the human-set goals. The World is a menu. It is never a hand +on the child's back. + +## Hard rules + +These are not conventions. Moving any of them requires a conscious, reviewed +decision, and most are already pinned in `test/invariants.test.ts`. If you add +capabilities described in these docs, extend that file to pin the new +boundaries too. + +1. The child never talks to a model. Activities are static HTML with logic + baked in at generation time. No chat box, ever. +2. The unattended tutor run has no Bash, no file read or write, no web fetch. + It reaches this record's MCP tools and nothing else. The World must be on + disk, reviewed, before any run starts. Never give the unattended run + network access to fetch World content live. +3. The parent review gate is enforced server-side. No model-reachable tool can + change settings, approve its own work, or lift the gate. +4. Nothing phones home. Generated activities are served under a CSP that blocks + all external requests. The World pipeline (see `docs/OPENCLAW.md`) runs on + the maintainer's machine only and must never touch a real family record. +5. No streaks, coins, leaderboards, or countdown timers for children. +6. No World content varies by demographic background. Prior knowledge and + language of instruction are legitimate dimensions. Anything shading into + demographic targeting is gated behind specialist review with no autonomous + path at all. +7. Agents never merge. Every World change enters through a pull request + admitted by a named human. Agents find, propose, and attack. Humans admit. +8. Never fabricate testimonials, user counts, outcomes, or citations. Never + present unmerged work as shipped. Provenance is machine-readable and every + claim carries its source. + +## Map of this repository's planning docs + +- `docs/WORLD.md` describes The World: what it contains, its schemas, its + admission pipeline, and the build order. This is what to build. +- `docs/OPENCLAW.md` describes the maintainer-side OpenClaw pipeline that + keeps The World current: agents, watchers, worktrees, and the security + gates. This is how it runs. Claude Code sessions have no prior knowledge of + OpenClaw; that file is self-contained. + +## Working in this repository + +- Node 22+. Storage is `node:sqlite`. One runtime dependency (the MCP SDK). + `npm test` runs the full suite; run it before and after changes. +- Curriculum is JSON in `src/curriculum/`: a `domain`, skills with `prereq` + edges and a `probe` template. `loadCurriculum` is idempotent and never + touches a learner's evidence. World content reaches families through this + path and through release artifacts, nothing else. +- Migrations are forward-only, numbered files in `src/db/migrations`. Nothing + drops a column. +- Mastery is a cache. Any model change must keep `recompute_mastery` able to + replay the whole evidence history. + +## Voice + +For anything written for the project (docs, site copy, PR descriptions, error +messages a parent will read): measured, concrete, quietly forceful. Short +declarative sentences. No exclamation points, no startup superlatives, no +fear-mongering, no em dashes. Candor is the brand. The prime/Primer wordplay +is used once and prominently, nowhere else. diff --git a/automation/watchers/spacing-literature.js b/automation/watchers/spacing-literature.js new file mode 100644 index 0000000..81d65b9 --- /dev/null +++ b/automation/watchers/spacing-literature.js @@ -0,0 +1,171 @@ +// Research-lane watcher: one claim family, one query, diff the DOI set. +// See docs/OPENCLAW.md. Read-only by contract; actions belong in the payload, +// because state from a failed payload run is not persisted. +// +// The runtime provides `tools` (tool calls under the owning agent's policy), +// `trigger` (carrying the frozen state from the last run), and `json` (how a +// watcher returns its verdict). The payload runs only on `fire: true`. +// +// Why a DOI set and not a content hash: hashing a page fires on view counters, +// rotating sidebars, and session tokens, so the watcher would wake the model +// every week and the maintainer would learn to ignore it. New DOIs since the +// last check are a real signal. Changed bytes are not. +// +// Why an ISSN allowlist: "spacing" and "retention" are cross-disciplinary +// homonyms. A relevance query alone returns aerospace, civil engineering, and +// nurse-staffing papers. Measured against the live API on 2026-07-30, the +// unrestricted query matched 153,105 works; restricted to these journals it +// matched 45, all on topic. Precision matters more than recall here, because +// this feeds an agent that writes citations. The proposer can web_search +// freely for anything this misses. + +const CLAIM_FAMILY = 'spacing'; // -> world/claims/spacing.md + +// ISSNs verified against api.crossref.org/journals on 2026-07-30. The list +// deliberately covers the venues world/claims/spacing.md already cites: a +// watcher that cannot see the journals its own claims came from would never +// notice those claims being superseded. +const JOURNALS = [ + '0033-2909', // Psychological Bulletin (Cepeda et al. 2006) + '0956-7976', // Psychological Science (Cepeda 2008; Roediger & Karpicke 2006) + '0278-7393', // Journal of Experimental Psychology: Learning, Memory, and Cognition + '0749-596X', // Journal of Memory and Language + '0022-0663', // Journal of Educational Psychology + '2211-3681', // Journal of Applied Research in Memory and Cognition + '1040-726X', // Educational Psychology Review + '0007-0998', // British Journal of Educational Psychology + '0888-4080', // Applied Cognitive Psychology +]; + +const QUERY = 'spacing distributed practice retrieval retention'; + +// TODO(operator): set a real contact address before registering this watcher. +// Crossref reserves a faster pool for callers who identify themselves, and a +// working address is how they reach you before they rate-limit you. +const MAILTO = 'tonystark@tamu.edu'; + +// Measured across the journals above on 2026-07-30: 33 works in 12 months, +// about 0.6 per week, with 0 in the trailing 14 days. At this rate a threshold +// of 2 would leave the pipeline silent for months and indistinguishable from a +// broken one, so it fires on a single new paper. Raise it if the queue ever +// outpaces review, which is the failure docs/OPENCLAW.md actually warns about. +const MIN_NEW = 1; +const POLL_DAYS = 14; // lookback per run, comfortably wider than the 7d cadence +const COLD_LOOKBACK = 30; // days, first run only +const KEEP = 300; // rolling DOI cap; ~30 bytes each against a 16 KB state limit + +const days = trigger.state ? POLL_DAYS : COLD_LOOKBACK; +const since = new Date(Date.now() - days * 864e5).toISOString().slice(0, 10); + +const filters = [ + `from-created-date:${since}`, + 'type:journal-article', + ...JOURNALS.map((issn) => `issn:${issn}`), +].join(','); + +const url = + 'https://api.crossref.org/works' + + `?query.bibliographic=${encodeURIComponent(QUERY)}` + + `&filter=${filters}` + + '&select=DOI,title,created&sort=created&order=desc&rows=50' + + `&mailto=${encodeURIComponent(MAILTO)}`; + +// How many consecutive failures before breakage is worth a model turn. The +// attacker watcher learned this the expensive way: firing on the first failed +// check cost eight aborted runs and eight alerts in a day, because the machine +// was offline and waking an agent to diagnose a missing network is +// self-defeating. Its own provider call needs the same network. A call that +// never completed is counted. A call that completed and came back wrong is +// announced at once, since a response arriving proves the network is up and +// something about the Crossref contract has actually changed. +// +// This watcher runs weekly, so a streak of 2 means one missed cycle before the +// maintainer hears about it. That is the right trade against a guaranteed +// wasted run every time the laptop happens to be asleep on the hour. +const CONNECT_ALERT_AFTER = 2; +const SHAPE_ALERT_AFTER = 1; + +let items = null; +let failure = ''; +let shapeFailure = false; +try { + const res = await tools.call('exec', { + command: `curl -sS --max-time 20 '${url}'`, + }); + const body = JSON.parse(String(res?.result?.details?.aggregated ?? '')); + if (body?.status !== 'ok' || !Array.isArray(body?.message?.items)) { + failure = `unexpected Crossref response shape (status ${body?.status ?? 'none'})`; + shapeFailure = true; + } else { + items = body.message.items; + } +} catch (err) { + failure = String(err).slice(0, 200); +} + +if (failure) { + // A watcher that goes quiet when its check breaks is indistinguishable from a + // healthy watcher with nothing to report, so persistent breakage still has to + // speak. It just does not have to speak on the first stumble. + const prev = trigger.state ?? {}; + const streak = (prev.failStreak ?? 0) + 1; + const firstFailAt = prev.firstFailAt ?? Date.now(); + const threshold = + prev.nextAlertAt ?? (shapeFailure ? SHAPE_ALERT_AFTER : CONNECT_ALERT_AFTER); + const alert = streak >= threshold; + const days = Math.round((Date.now() - firstFailAt) / 864e5); + + json({ + fire: alert, + message: alert + ? `WATCHER BROKEN (${CLAIM_FAMILY}): the Crossref check has failed ` + + `${streak} time${streak === 1 ? '' : 's'} in a row, over about ` + + `${days} day${days === 1 ? '' : 's'}. Latest: ${failure}. Do not ` + + 'propose claims from this run. If your own web and provider calls are ' + + 'also failing, this is the machine being offline rather than the ' + + 'watcher being wrong: say that in one sentence and stop, do not retry ' + + 'in a loop. Otherwise diagnose it and report.' + : undefined, + // Spread the previous state so the DOI set survives an outage. Wiping it + // would make the next successful run treat several hundred known papers as + // new. Both success paths below write fresh state without these counters, + // which is how a recovered check resets its streak and its backoff. + state: { + ...prev, + failStreak: streak, + firstFailAt, + nextAlertAt: alert ? streak * 4 : threshold, + lastError: failure.slice(0, 120), + }, + }); +} else { + const seen = new Set(trigger.state?.dois ?? []); + const fresh = items.filter((w) => w.DOI && !seen.has(w.DOI)); + const dois = [...seen, ...fresh.map((w) => w.DOI)].slice(-KEEP); + + if (!trigger.state) { + // Seed silently. Firing on the first run would hand the proposer a batch + // of everything published in the lookback window, which is not one + // coherent proposal and cannot become one PR. + json({ fire: false, state: { dois, seededAt: Date.now() } }); + } else { + json({ + // The titles and DOIs travel in the message because the fired payload + // receives this string as its entire event context. "Something changed" + // would force the proposer to re-derive what the watcher already knew. + fire: fresh.length >= MIN_NEW, + message: + `${fresh.length} new works in the ${CLAIM_FAMILY} claim family:\n` + + fresh.map((w) => `- ${w.title?.[0] ?? '(untitled)'} — ${w.DOI}`).join('\n') + + '\n\nRead these, then follow the proposer protocol in docs/OPENCLAW.md. ' + + `Target: world/claims/${CLAIM_FAMILY}.md and any file in world/priors/ ` + + 'carrying depends_on_claims for this family. Resolve every DOI before ' + + 'citing it. A TODO source is honest; an invented DOI is a firing offense. ' + + 'The ISSN filter is a venue filter, not a topic filter, so some of these ' + + 'will be off topic. If none of them warrants a change, reply saying so in ' + + 'one sentence and open no PR. An empty week is a normal outcome; a PR ' + + 'manufactured to look productive is not.', + state: { dois, checkedAt: Date.now() }, + }); + } +} diff --git a/automation/watchers/world-proposals.js b/automation/watchers/world-proposals.js new file mode 100644 index 0000000..f6043d2 --- /dev/null +++ b/automation/watchers/world-proposals.js @@ -0,0 +1,210 @@ +// Attacker-lane watcher: layer 2 of the admission pipeline in docs/WORLD.md. +// Polls open pull requests, fires on ones carrying World content this lane has +// not attacked yet. See docs/OPENCLAW.md. Read-only by contract; the comment +// itself belongs in the payload, because state from a failed payload run is +// not persisted and a PR marked attacked but never commented on is worse than +// one attacked twice. +// +// The runtime provides `tools` (tool calls under the owning agent's policy), +// `trigger` (carrying the frozen state from the last run), and `json` (how a +// watcher returns its verdict). The payload runs only on `fire: true`. +// +// Why changed paths and not the branch prefix: docs/OPENCLAW.md sketches this +// watcher as `--search "head:openclaw/"`, which scopes it to agent-authored +// branches. That is the wrong axis. The attack brief is about miscited papers, +// skills placed a grade early, and agreement counts that dissolve under +// inspection: failure modes of World content, not of agents. A human PR that +// seeds world/claims/ has every one of them. PR #7, the first World slice, is +// human-authored, and it is exactly the PR that definition-of-done step 4 +// says the attacker must post findings on. A branch-prefix filter would skip +// it. Meanwhile an agent PR touching only src/ would have nothing to attack. +// So: any author, any branch, filtered to PRs that change a file under world/. + +const REPO = 'VedSoni-dev/primer'; + +// A path is World content when it lives under world/ and is not a directory +// placeholder. A PR that only adds .gitkeep files has moved no claim, no prior, +// and no edge, so there is nothing in it to attack. +const isWorldContent = (path) => + String(path ?? '').startsWith('world/') && !String(path).endsWith('.gitkeep'); + +// Whether a PR has already been attacked at a given head is answered by the +// pull request itself, not by trigger state. The attacker signs every comment +// with the head it attacked, so the durable record lives on GitHub where it +// cannot be lost. Trigger state is a cache in front of that, nothing more. +// +// This is not defensive decoration. Trigger state is empty after a forced +// `openclaw cron run`, which executes the payload without ever evaluating the +// watcher, and after any job edit that resets it. Observed: the first attack on +// PR #7 was a forced run, so the next scheduled evaluation would have found an +// empty seen-set and attacked it a second time. A duplicate attack comment is +// the exact thing that teaches a reviewer to skim this layer. +const attackedAt = (body, sha) => { + const b = String(body ?? ''); + return ( + b.includes(``) || + new RegExp(`attacked at head ${sha}`, 'i').test(b) + ); +}; + +// The turn that fires attacks every PR named in the message. Each one is a +// close read of a diff plus source resolution, so a run that names ten is a +// run that reads none of them properly. Overflow is not dropped, it waits for +// the next run, and the message says how many are waiting. +const MAX_PER_RUN = 3; + +// ~50 bytes per entry against a 16 KB state cap. +const KEEP = 200; + +// How many consecutive failures before breakage is worth a model turn. +// +// Firing on the first failed check was wrong and cost eight aborted runs and +// eight Telegram alerts in one day. This runs on a laptop that sleeps and +// changes networks. When the gh call fails for that reason, waking an agent to +// diagnose it is self-defeating: the same missing network prevents the agent's +// own provider call, so the run hangs for twenty minutes and aborts. The +// diagnosis cannot run under the condition it was written to diagnose. +// +// So a call that never completed is counted, not announced. A call that +// completed and returned the wrong shape is announced at once, because a +// response arrived, which means the network is up, the agent can actually run, +// and something about the gh contract has genuinely changed. +const CONNECT_ALERT_AFTER = 3; // hourly cadence, so roughly three hours down +const SHAPE_ALERT_AFTER = 1; + +let prs = null; +let failure = ''; +let shapeFailure = false; +try { + const res = await tools.call('exec', { + command: + `gh pr list --repo ${REPO} --state open --limit 50 ` + + '--json number,title,headRefName,headRefOid,isDraft,url,files,comments', + }); + const body = JSON.parse(String(res?.result?.details?.aggregated ?? '')); + if (!Array.isArray(body)) { + failure = 'unexpected gh response shape (expected a JSON array)'; + shapeFailure = true; + } else { + prs = body; + } +} catch (err) { + failure = String(err).slice(0, 200); +} + +if (failure) { + // A watcher that goes quiet when its check breaks is indistinguishable from a + // healthy watcher with nothing to report, so persistent breakage still has to + // speak. It just does not have to speak on the first stumble. + const prev = trigger.state ?? {}; + const streak = (prev.failStreak ?? 0) + 1; + const firstFailAt = prev.firstFailAt ?? Date.now(); + const threshold = + prev.nextAlertAt ?? (shapeFailure ? SHAPE_ALERT_AFTER : CONNECT_ALERT_AFTER); + const alert = streak >= threshold; + + // Back off after each alert, so a genuinely long outage reports at three + // hours, then twelve, then two days, instead of once an hour forever. Nothing + // is learned from the ninth identical alert that the first did not say. + const hours = Math.round((Date.now() - firstFailAt) / 36e5); + + json({ + fire: alert, + message: alert + ? `WATCHER BROKEN (world-proposals): the gh check has failed ${streak} ` + + `time${streak === 1 ? '' : 's'} in a row, over about ${hours} hour` + + `${hours === 1 ? '' : 's'}. Latest: ${failure}. Post no PR comments ` + + 'from this run. If your own web and provider calls are also failing, ' + + 'this is the machine being offline rather than the watcher being ' + + 'wrong: say that in one sentence and stop, do not retry in a loop. ' + + 'Otherwise diagnose it, starting with `gh auth status`, and report.' + : undefined, + // Spread the previous state so the seen-set survives an outage. Wiping it + // would re-attack every open PR the moment the network came back. + state: { + ...prev, + failStreak: streak, + firstFailAt, + nextAlertAt: alert ? streak * 4 : threshold, + lastError: failure.slice(0, 120), + }, + }); +} else { + // Keyed by head commit, not PR number. A proposer that pushes a fix in + // response to an attack has produced a different proposal, and the previous + // attack no longer applies to it. Keying on number alone would attack the + // first revision of a PR and nothing after it. The attacker cannot write, so + // its own comment never moves the head and never re-fires this. + const seen = new Set(trigger.state?.seen ?? []); + const short = (p) => String(p.headRefOid ?? '').slice(0, 7); + const key = (p) => `${p.number}@${String(p.headRefOid ?? '').slice(0, 12)}`; + + const candidates = prs.filter( + (p) => + !p.isDraft && // a draft is not a proposal yet + Array.isArray(p.files) && + p.files.some((f) => isWorldContent(f.path)), + ); + const fresh = candidates.filter( + (p) => + !seen.has(key(p)) && + !(p.comments ?? []).some((c) => attackedAt(c.body, short(p))), + ); + + // No silent cold-start seed here, unlike the research watcher. There, a first + // run holds a lookback window of unrelated papers that cannot become one + // coherent proposal. Here, a backlog of open World PRs is precisely the thing + // that wants attacking, and each one is already its own coherent unit. + const batch = fresh.slice(0, MAX_PER_RUN); + const waiting = fresh.length - batch.length; + + if (batch.length === 0) { + // Both success paths write fresh state without the failure counters, which + // is how a recovered check resets its streak and its backoff. + json({ fire: false, state: { seen: [...seen], checkedAt: Date.now() } }); + } else { + const describe = (p) => { + const worldFiles = p.files + .filter((f) => isWorldContent(f.path)) + .map((f) => ` ${f.path} (+${f.additions}/-${f.deletions})`) + .join('\n'); + return `- #${p.number} ${p.title}\n ${p.url}\n head ${short(p)}\n${worldFiles}`; + }; + + json({ + fire: true, + // The numbers, titles, and touched World files travel in the message + // because the fired payload receives this string as its entire event + // context. "A PR is open" would force the attacker to re-derive what the + // watcher already knew. + message: + `${batch.length} pull request${batch.length === 1 ? '' : 's'} ` + + 'carrying World content, not yet attacked:\n' + + batch.map(describe).join('\n') + + (waiting > 0 + ? `\n\n${waiting} more are waiting and will be picked up on the next ` + + 'run; this run is capped so each PR gets a real read.' + : '') + + '\n\nAttack each one on its own terms, then post with `gh pr comment`. ' + + 'Begin every comment with the line `` ' + + 'using that PR head above, then a sentence naming yourself and the head ' + + 'you attacked. The marker is how the next run knows this head is done, ' + + 'and the naming is because your comment posts under the maintainer\'s ' + + 'GitHub account and must not read as the maintainer\'s own words. ' + + 'Read the full diff before writing anything: ' + + `\`gh pr diff --repo ${REPO}\`. Resolve every DOI and every ` + + 'source the PR cites; a citation that does not resolve, or resolves to ' + + 'something other than what the PR says it says, is the finding. You ' + + 'approve nothing and you merge nothing, and your comment is never a ' + + 'merge signal. If a proposal survives the attack, say so in one ' + + 'sentence and post that. A manufactured objection wastes the one ' + + 'human in this pipeline, which is the only scarce thing in it.', + state: { + // Only the batch is marked seen. The overflow stays fresh so the next + // run picks it up instead of losing it. + seen: [...seen, ...batch.map(key)].slice(-KEEP), + checkedAt: Date.now(), + }, + }); + } +} diff --git a/docs/OPENCLAW.md b/docs/OPENCLAW.md new file mode 100644 index 0000000..1bc8b8b --- /dev/null +++ b/docs/OPENCLAW.md @@ -0,0 +1,305 @@ +# The World pipeline, on OpenClaw + +This file is self-contained. It assumes no prior knowledge of OpenClaw and +specifies exactly how the maintainer-side pipeline that keeps The World +current is built and operated. Read `docs/WORLD.md` first for what the +pipeline produces and why. + +## What OpenClaw is + +OpenClaw (docs: https://docs.openclaw.ai) is a self-hosted gateway daemon that +connects messaging channels (Telegram, WhatsApp, Slack, a web dashboard) to AI +agents running on your own machine. It provides isolated agents, a persistent +scheduler (cron with condition watchers), managed git worktrees, and per-agent +tool policies. MIT licensed, runs on Node 22+. + +```bash +npm install -g openclaw@latest +openclaw onboard --install-daemon +openclaw dashboard # Control UI at http://127.0.0.1:18789 +``` + +Configuration lives at `~/.openclaw/openclaw.json` (JSON5). The Gateway must +be running for schedules to fire; jobs persist in SQLite across restarts. + +## The boundary that governs everything + +OpenClaw is a maintainer tool. It maintains this repository. It is +architecturally the inverse of primer's runtime guarantees: it is a daemon +with network access and chat channels. Therefore: + +- It runs on the maintainer's machine only, scoped to this repo. +- It must never read, write, or know the path of a real family record. All + pipeline work uses synthetic fixtures in `test/fixtures/`. +- It is never a dependency of primer, never mentioned in family-facing docs, + and never part of any install path. +- No pipeline agent gets the `message` tool beyond announce delivery to the + maintainer's own channel. + +Two OpenClaw facts to internalize before configuring anything: + +1. An agent's workspace is a default working directory, not a sandbox. + Relative paths resolve inside it, but absolute paths reach the whole host + unless Docker sandboxing is enabled. Deny-lists matter. +2. Setting `cron.triggers.enabled: true` allows watcher scripts and script + payloads to run headlessly with the owning agent's full tool policy, + including `exec`. It is required for this pipeline and is unattended code + execution. Keep every pipeline agent's tool policy minimal because of it. + +## Agents + +Three isolated agents, one per lane, so each carries its own workspace, +session store, and tool allow/deny policy. Plus one attacker. + +```bash +openclaw agents add world-research --workspace ~/code/primer +openclaw agents add world-curriculum --workspace ~/code/primer +openclaw agents add world-careers --workspace ~/code/primer +openclaw agents add world-attacker --workspace ~/code/primer +openclaw agents list --bindings +``` + +Tool policy, in `~/.openclaw/openclaw.json` under `agents.list`: + +- `world-research`, `world-curriculum`, `world-careers`: allow `exec`, `read`, + `write`, `edit`, `web_search`, `web_fetch`. Deny `browser`, `nodes`, + `canvas`. They need `exec` for git and `gh`, and network tools to source. +- `world-attacker`: allow `exec`, `read`, `web_search`, `web_fetch`. Deny + `write`, `edit`, `apply_patch`. It comments on PRs via `gh pr comment` + through `exec`; it never modifies files. + +Each agent's workspace `AGENTS.md` contains its lane's charter, copied from the +lane description in `docs/WORLD.md`, plus the two standing rules: propose via +PR only, and never touch anything outside `world/` and the lane's fixtures. + +All four agents take this repository as their workspace, so there is one +`AGENTS.md` at the root carrying all four charters, and each agent reads the +section for the id it is running as. That file is tracked in git and is the +only OpenClaw scaffolding file in this repository that is; the rest +(`SOUL.md`, `USER.md`, `TOOLS.md`, `memory/`) is generated, gitignored, and +maintainer-side. The charter is not scaffolding. It is the instruction set +every unattended run wakes up with, and it belongs under review like anything +else that governs what these agents do. + +Useful property to rely on: cron jobs created by an agent are capped to the +tools available to the creating turn, and the agent cannot widen the stored +list. Jobs you create as operator should still pass `--tools` explicitly. + +## Repository hooks for worktrees + +OpenClaw's managed worktrees give each proposal task its own branch and +checkout under `~/.openclaw/worktrees/`, outside this repo, named +`openclaw/`. Contents are snapshotted before removal; dirty or unpushed +worktrees are never auto-deleted; snapshots restore for 30 days. + +Two files in this repo make worktrees work well: + +`.worktreeinclude` at the repo root (gitignore syntax) copies selected +gitignored files into each fresh worktree: + +```gitignore +.env.local +test/fixtures/generated/** +``` + +`.openclaw/worktree-setup.sh`, executable, runs in each new worktree; nonzero +exit aborts creation: + +```bash +#!/usr/bin/env bash +set -euo pipefail +npm ci +npm test +``` + +## The three job types per lane + +Enable the dangerous-automation gate once, deliberately: + +```json5 +{ cron: { enabled: true, triggers: { enabled: true } } } +``` + +### 1. The watcher + +A condition script attached to a recurring schedule. OpenClaw evaluates it +when due; the payload runs only when the script returns `fire: true`. Previous +state arrives as the frozen `trigger.state`; returning `state` persists it +(16 KB cap). Write watchers as read-only checks; keep actions in the payload, +because state from a failed payload run is not persisted. + +`automation/watchers/spacing-literature.js` (research lane example): + +```js +// Poll one source, diff against last known state, fire only on change. +const res = await tools.call('exec', { + command: 'curl -s | sha256sum | cut -d" " -f1' +}); +const hash = String(res?.result?.details?.aggregated ?? '').trim(); +json({ + fire: hash !== trigger.state?.hash, + message: `Spacing-literature source changed (${hash.slice(0,8)}). ` + + `Diff against world/claims/spacing.md and propose an update.`, + state: { hash, checkedAt: Date.now() } +}); +``` + +Register it: + +```bash +openclaw cron add \ + --name "research: spacing watcher" \ + --agent world-research \ + --every 7d \ + --trigger-script ./automation/watchers/spacing-literature.js \ + --session isolated \ + --tools exec,read,write,edit,web_search,web_fetch \ + --message "A watched source changed. Follow the proposer protocol in docs/OPENCLAW.md." \ + --announce --channel telegram --to "" +``` + +Cadence: weekly for standards and literature. These sources move slowly; +there is no reason to hammer anyone's server. Author watchers around +actionable state, not only success: a watcher that goes quiet when its check +fails looks healthy while broken, so surface fetch failures in `message`. + +Surface them on a streak, though, not on the first stumble. This runs on a +laptop that sleeps and changes networks, and firing a model turn to diagnose a +missing network is self-defeating, because the agent's own provider call needs +the same network that just failed. Measured on the attacker watcher: one +offline stretch produced eight fired runs, each hanging until it aborted, and +eight failure alerts. The rule both watchers now follow is that a call which +never completed is counted, while a call that completed and returned the wrong +shape is announced at once, since a response arriving proves the network is up +and the source contract is what changed. Alerts back off after each firing, so +a long outage reports a few times rather than once an hour forever, and the +counters reset the moment a check succeeds. + +Give every job a `--timeout-seconds` too. A run with no ceiling that loses the +network does not fail, it hangs: the aborted attacker runs above burned between +sixteen and twenty-three minutes each before giving up. + +### 2. The proposer protocol + +The fired payload runs as an isolated agent turn. Isolated cron runs are +unattended by contract: the final reply must be the deliverable, not a plan or +a question. The protocol every lane follows: + +1. Create a managed worktree: `openclaw worktrees create ~/code/primer + --name -` (branch becomes `openclaw/-`). +2. Read the current World entry the change touches. +3. Write the delta: the claims file, priors JSON, skill pack, or lens file, + with a complete provenance block, `review: "unreviewed"`, `reviewer: null`. +4. Run the mechanical validators locally: `npm test` plus + `node scripts/validate-world.mjs` (schema, cycles, duplicates, provenance + completeness, agreement counts; create this script if absent, mirroring + the checks listed in docs/WORLD.md layer 1). +5. Commit, push, open a PR with `gh pr create`. The PR body states what + changed, why, the sources, and the agreement count. It never claims the + change is correct; it claims the change is proposed. +6. Reply with the PR URL as the deliverable. + +Agents never merge. Nothing in any prompt, config, or convenience change may +give a pipeline agent the ability to admit its own work. + +### 3. The attacker + +A watcher on open PRs, bound to `world-attacker`: +`automation/watchers/world-proposals.js`. It polls `gh pr list`, keeps a +seen-set in trigger state, and fires on what it has not attacked yet. + +```bash +openclaw cron add \ + --name "attacker: world proposals" \ + --agent world-attacker \ + --every 1h \ + --trigger-script ./automation/watchers/world-proposals.js \ + --session isolated \ + --tools exec,read,web_search,web_fetch \ + --message "" \ + --announce --channel telegram --to "" +``` + +Two things it does differently from the obvious version, both load-bearing: + +It selects PRs by changed path, not by branch prefix. Scoping to +`head:openclaw/` would attack only agent branches, and the failure modes in the +brief below belong to World content rather than to agents. The first World PR +in this repository is human-authored; a branch filter would have skipped it, +and a human PR touching only `src/` has nothing in it to attack. The filter is +therefore: any author, any branch, at least one changed file under `world/`. + +It keys the seen-set on PR number plus head commit, so a proposal pushed again +after an attack gets attacked again. Keying on the number alone attacks the +first revision and nothing after it. The attacker holds no `write` tool, so its +own comment never moves a head and never re-fires the watcher. + +The seen-set is a cache, not the record. Every attack comment opens with +``, and the watcher treats the pull request +itself as the durable answer to whether a head has been attacked. Trigger state +is lost more easily than it looks: `openclaw cron run` executes the payload +without evaluating the watcher at all, so a forced run leaves the state empty +and the next scheduled evaluation would attack a pull request that already +carries the findings. A duplicate attack comment is precisely what teaches a +reviewer to skim this layer. + +The signature is also an attribution fix. The attacker posts through the +maintainer's `gh` credentials, so its comments appear under the maintainer's +GitHub account with nothing marking them machine-authored. The brief requires +naming itself in the first sentence. Layer 2 saying something is not the same +as the human saying it, and the record has to show which one spoke. + +Cadence is hourly rather than weekly. A run that finds nothing fresh costs only +the script budget, and a PR is worth attacking while its author is still +looking at it. Each fired run is capped to a few PRs so that each one gets a +real read; the overflow stays unseen and is picked up on the next run, and the +message says how many are waiting. + +Its payload prompt is a standing attack brief, not a review rubric: find the +reason this proposal is wrong. A miscited paper. A skill placed a grade early. +A prerequisite edge that only holds for one teaching sequence. An agreement +count that dissolves because two sources share an upstream. Post findings with +`gh pr comment`. If nothing survives the attack, say so in one sentence; do +not manufacture objections. The attacker filters what reaches the human +reviewer. It approves nothing and its comment is never a merge signal. + +## Delivery and operations + +`--announce --channel telegram --to ` sends each run's result to your +phone, which is the point of using OpenClaw at all for a solo maintainer: the +pipeline runs whether or not you are at the machine, and you review PRs from +wherever you are. Set `cron.failureDestination` so failed runs alert the same +channel; silent failure is the default failure mode of unattended systems. + +Routine operations: + +```bash +openclaw cron list # all jobs +openclaw cron runs --id # run history for one job +openclaw cron run --wait # force a run now, block on result +openclaw worktrees list # open proposal checkouts +openclaw tasks audit # everything that ran, and when +openclaw logs --follow # live gateway logs +``` + +Budget note: each isolated run is a model-backed turn. Weekly watchers that +mostly return `fire: false` cost nothing beyond the 30-second script budget. +Keep it that way; the failure mode of an always-on pipeline is not running too +rarely, it is generating a queue nobody reviews. + +## Definition of done for the first slice + +The pipeline exists when this sequence has happened once, end to end, with a +human at only the last step: + +1. The spacing-literature watcher fires on a real change. +2. `world-research` opens a PR containing one claims file and one priors + proposal with complete provenance. +3. CI runs the mechanical validators on the PR. +4. `world-attacker` posts its findings. +5. A named human merges, sets `review: "specialist-reviewed"` where earned, + bumps `WORLD_VERSION`, and the next primer release carries it. +6. `primer fit` on the synthetic fixture record consumes the new priors. + +Then repeat the shape for grade 4 curriculum, then lenses, per the build +order in `docs/WORLD.md`. diff --git a/docs/WORLD.md b/docs/WORLD.md new file mode 100644 index 0000000..b018034 --- /dev/null +++ b/docs/WORLD.md @@ -0,0 +1,182 @@ +# The World + +The Record answers "what does this child know." It cannot answer "what is worth +knowing, what order does the field teach it in, and what does the evidence say +about how memory works." That second body of knowledge already exists in this +repository, unnamed and frozen at authoring time: the 213-skill curriculum, the +prerequisite graph, the probe templates, and the BKT parameters that SPEC.md +honestly labels principled defaults rather than fitted values. + +The World is that body of knowledge, given a name, a schema, and a lifecycle. + +## First principle: an artifact, not a service + +The World is versioned data in this repository. It ships inside primer +releases and enters a family's machine through `loadCurriculum` and the +existing import path. It is reviewed before it ships and static after. + +This preserves the invariant that matters most: the unattended tutor run has +no network access and reaches nothing but the record's MCP tools. The tutor +never fetches The World. It is already on disk. Families never run the +pipeline that maintains it and never know it exists. + +## Directory layout + +``` +world/ + claims/ pedagogy claims with citations (research lane) + spacing.md + bkt-parameters.md + priors/ parameter priors derived from claims + bkt-priors.json + curriculum/ skill packs and edges (curriculum lane) + (extends src/curriculum/; same format, plus provenance) + lenses/ interest and career lenses (careers lane) + engineering/ + math.grade-2.measurement.json + reading.grade-1.blends.json + trajectories/ upper-grade path metadata (careers lane) + engineering.json + WORLD_VERSION single line, semver, bumped on any admitted change +``` + +If `world/` does not exist yet, creating this layout is the first task. +`src/curriculum/` remains the loading path families consume; `world/curriculum/` +is where proposals land and are reviewed before being promoted into it. + +## Provenance + +Every World entry carries a provenance block. No exceptions, including +human-authored entries. + +```json +"provenance": { + "sources": ["", ""], + "retrieved": "2026-07-30", + "agreement": 4, + "review": "unreviewed", + "reviewer": null, + "depends_on_claims": ["spacing/half-life-growth", "bkt/guess-two-choice"] +} +``` + +- `agreement` counts independent sources attesting the same placement or + claim. Computed, not asserted. +- `review` is one of `unreviewed`, `specialist-reviewed`, `field-tested`. + Nothing self-promotes. Only a human moves it, and the move is a commit. +- `depends_on_claims` links parameters and curriculum decisions back to the + claims files, so a retracted claim can be traced to everything downstream. +- The `reviewer` is a named person. An agent cannot appear in this field. + +## The three lanes + +### Research lane + +Sources: learning-science literature. Spaced repetition, testing effect, +Bayesian Knowledge Tracing parameter estimation, accommodation design +evidence. + +Output, two artifacts: + +1. **Claims files** (`world/claims/*.md`). One claim per entry: the claim in + one sentence, citations, strength of evidence, and what in primer depends + on it. Plain text, because pedagogy in this project is inspectable by + anyone. +2. **Parameter priors** (`world/priors/*.json`). Per-skill-type guess, slip, + learn, and forgetting-curve priors, each carrying `depends_on_claims`. + These feed `primer fit` as starting points. They never overwrite fitted + values from real evidence; fitted always beats prior. + +Papers do not map to skills. Do not let this lane produce curriculum. + +### Curriculum lane + +Sources: public standards frameworks and scope-and-sequence documents. + +Output: skill packs and prerequisite edges in the existing curriculum JSON +format plus provenance. Priority is the stated gap: grade 4 and up, and +languages beyond English. Cross-source `agreement` is the mechanizable half of +vetting; whether a sequence is pedagogically defensible is the human half, and +the pipeline must never blur that line. + +### Careers lane + +Sources: what practitioners in a field actually use, decomposed to the skills +underneath. + +Output, two artifacts: + +1. **Lenses** (`world/lenses//`). A lens is not a new skill tree. It is + a mapping from existing skill IDs to contexts, vocabulary, manipulatives, + and problem framings. Grade 2 measurement under an engineering lens becomes + bridge spans and ramp angles. The skill ID, the mastery model, and the + prerequisite graph do not change. +2. **Trajectories** (`world/trajectories/*.json`). For older learners: which + existing skills a path leans on hardest, and what new skills appear past + the current graph. Metadata about paths, never a selection of one. + +Lens activation is decided on the family's machine as a function of the +`goal` table (human-set) and the `interest` table (child-revealed, decaying). +A child who wants to be an engineer this year gets the engineering lens; when +that interest fades, the lens fades with it. The World never picks. + +## The admission pipeline + +Three layers. Each catches what the previous one structurally cannot. A model +validating a model has correlated failure modes, which is why layer two +attacks instead of approving and layer three is human. + +**Layer 1, mechanical.** Deterministic checks, no model: schema conformance, +prerequisite cycle detection, duplicate skill IDs, provenance completeness, +agreement counts, reading level of child-facing prompt text, every skill has +assessable success criteria. These run in CI on every pull request, human or +agent, and fail loudly. + +**Layer 2, adversarial.** A model prompted to find the reason a proposal is +wrong: a miscited paper, a skill placed a grade early, an edge that only holds +for one teaching sequence. Findings land as a PR comment. This layer filters +what reaches humans. It admits nothing. + +**Layer 3, human.** The merge. A named reviewer, recorded in provenance. +Specialists get a queue: entries filtered to `review: "unreviewed"`, sorted by +how many children the change would touch. For the curriculum graph this +liability is real: a wrong edge quietly mis-teaches a real child. An agent +cannot carry that. A named human can. + +## Composition at planning time + +On the family's machine, the tutor's planning run composes: + +``` +Record (mastery, misconceptions, interests, accommodations, affect) + + World (skills, edges, priors, active lens content) + + Goals (human-set intent) + = the session plan +``` + +The scheduler picks the skill from the Record's state against the World's +graph. The active lens, if any, supplies the costume. The tutor writes the +one-off activity. For the genuinely unanticipated case, the tutor's ability to +generate a bespoke activity from the whole child is already the answer, and it +is safe precisely because it is ephemeral and local. + +Pin the composition rule as an executable invariant alongside the existing +ones: no World data structure contains a learner ID, and no pipeline code path +can read a family record. The pipeline operates on synthetic fixtures only. + +## Build order + +1. **Research lane first.** It is the smallest artifact, it bridges + "principled defaults" to "fitted to data," and it exercises the entire + pipeline end to end: watch, propose, attack, admit, ship, consumed by + existing `fit` machinery with zero new family-side code. Start with one + watcher on spacing and BKT literature, one claims file, one priors + proposal. +2. **Curriculum lane second.** Grade 4 math, one standards source, one grade + band. Prove the diff-to-PR loop produces PRs a specialist finds worth + reading. About three PRs will tell you. +3. **Lenses third**, once the admission pipeline has proven itself on + lower-stakes content. Ship a handful; the format is JSON so specialists can + author them without code. + +The pipeline that runs these lanes is specified in `docs/OPENCLAW.md`. diff --git a/scripts/validate-world.mjs b/scripts/validate-world.mjs new file mode 100644 index 0000000..9d52bb9 --- /dev/null +++ b/scripts/validate-world.mjs @@ -0,0 +1,449 @@ +#!/usr/bin/env node +/** + * Layer 1 of the World admission pipeline: the mechanical checks. + * + * Deterministic, no model, no database, no network. Runs in CI on every pull + * request, human or agent, and fails loudly. The checks are the ones listed + * in docs/WORLD.md: schema conformance, prerequisite cycle detection, + * duplicate skill ids, provenance completeness, agreement counts, reading + * level of child-facing prompt text, and that every skill has assessable + * success criteria. + * + * This script must never import from src/ or open a database. It reads the + * World and the shipped curriculum JSON as plain files, nothing else. That + * boundary is pinned in test/invariants.test.ts. + */ +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import { join, relative, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const WORLD = join(ROOT, 'world'); + +const errors = []; +const fail = (file, message) => errors.push(`${relative(ROOT, file)}: ${message}`); + +if (!existsSync(WORLD)) { + console.error('world/ does not exist; nothing to validate'); + process.exit(1); +} + +/** Every file under a directory, recursively, ignoring .gitkeep. */ +function walk(dir) { + if (!existsSync(dir)) return []; + const out = []; + for (const name of readdirSync(dir)) { + if (name === '.gitkeep') continue; + const full = join(dir, name); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else out.push(full); + } + return out; +} + +function readJson(file) { + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch (e) { + fail(file, `not valid JSON: ${e.message}`); + return null; + } +} + +// --------------------------------------------------------------------------- +// WORLD_VERSION: a single semver line. +// --------------------------------------------------------------------------- +{ + const file = join(WORLD, 'WORLD_VERSION'); + if (!existsSync(file)) { + fail(file, 'missing'); + } else { + const text = readFileSync(file, 'utf8'); + if (!/^\d+\.\d+\.\d+\n?$/.test(text)) { + fail(file, `must be a single semver line, got ${JSON.stringify(text)}`); + } + } +} + +// --------------------------------------------------------------------------- +// Provenance completeness and agreement counts. Every World entry carries one. +// --------------------------------------------------------------------------- +const REVIEW_STATES = ['unreviewed', 'specialist-reviewed', 'field-tested']; +// The reviewer is a named person. An agent cannot appear in this field. +const AGENT_NAME = /\b(agent|bot|claude|gpt|gemini|llm|model|openclaw|assistant)\b/i; +const REAL_SOURCE = /^(https?:\/\/|doi:10\.|10\.\d)/; + +/** All depends_on_claims references, resolved after every claim is parsed. */ +const claimRefs = []; + +function checkProvenance(prov, file, where) { + const at = (msg) => fail(file, `${where}: ${msg}`); + if (!prov || typeof prov !== 'object') return at('missing provenance block'); + + const { sources, retrieved, agreement, review, reviewer, depends_on_claims } = prov; + + if (!Array.isArray(sources) || sources.length === 0 || sources.some((s) => typeof s !== 'string' || !s.trim())) { + at('provenance.sources must be a non-empty array of strings'); + } else { + const todo = sources.filter((s) => /^TODO\b/.test(s.trim())); + const real = sources.filter((s) => REAL_SOURCE.test(s.trim())); + const junk = sources.filter((s) => !/^TODO\b/.test(s.trim()) && !REAL_SOURCE.test(s.trim())); + for (const s of junk) at(`source ${JSON.stringify(s)} is neither a URL, a DOI, nor an explicit TODO`); + if (todo.length > 0 && review !== 'unreviewed') { + at('a TODO source is only honest while review is "unreviewed"'); + } + // Agreement counts independent sources attesting the claim. Computed, not + // asserted: mechanically we can at least refuse a count the listed sources + // cannot support. A TODO attests nothing. + if (!Number.isInteger(agreement) || agreement < 0) { + at('provenance.agreement must be a non-negative integer'); + } else if (agreement > new Set(real.map((s) => s.trim())).size) { + at(`provenance.agreement is ${agreement} but only ${real.length} resolvable sources are listed`); + } + } + + if (typeof retrieved !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(retrieved)) { + at('provenance.retrieved must be a YYYY-MM-DD date'); + } + + if (!REVIEW_STATES.includes(review)) { + at(`provenance.review must be one of ${REVIEW_STATES.join(', ')}`); + } + + if (review === 'unreviewed') { + if (reviewer !== null) at('provenance.reviewer must be null while unreviewed; only a human move sets it'); + } else { + if (typeof reviewer !== 'string' || !reviewer.trim()) { + at('a reviewed entry names its reviewer'); + } else if (AGENT_NAME.test(reviewer)) { + at(`provenance.reviewer ${JSON.stringify(reviewer)} reads like an agent; the reviewer is a named person`); + } + } + + if (depends_on_claims !== undefined) { + if (!Array.isArray(depends_on_claims) || depends_on_claims.some((c) => typeof c !== 'string')) { + at('provenance.depends_on_claims must be an array of claim ids'); + } else { + for (const ref of depends_on_claims) claimRefs.push({ ref, file, where }); + } + } +} + +// --------------------------------------------------------------------------- +// Claims: one claim per entry, parsed from world/claims/*.md. +// --------------------------------------------------------------------------- +const claimIds = new Set(); +const CLAIM_ID = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/; + +for (const file of walk(join(WORLD, 'claims'))) { + if (!file.endsWith('.md')) { + fail(file, 'claims are markdown; unexpected file type'); + continue; + } + const text = readFileSync(file, 'utf8'); + const sections = text.split(/^## /m).slice(1); + if (sections.length === 0) { + fail(file, 'no claim entries (## headings) found'); + continue; + } + for (const section of sections) { + const id = section.slice(0, section.indexOf('\n')).trim(); + const at = (msg) => fail(file, `claim "${id}": ${msg}`); + + if (!CLAIM_ID.test(id)) at('claim id must look like namespace/slug, lowercase'); + if (claimIds.has(id)) at('duplicate claim id'); + claimIds.add(id); + + const claim = section.match(/^\*\*Claim\.\*\* (.+)$/m); + if (!claim) at('missing a one-line "**Claim.** ..." sentence'); + else if (!claim[1].trim().endsWith('.')) at('the claim must be a sentence'); + + const strength = section.match(/^\*\*Strength\.\*\* (\w+)/m); + if (!strength || !['strong', 'moderate', 'weak'].includes(strength[1])) { + at('missing "**Strength.** strong|moderate|weak"'); + } + + if (!/^\*\*Depends on this\.\*\* .+$/m.test(section)) { + at('missing "**Depends on this.** ..." naming what in primer depends on it'); + } + + const fences = [...section.matchAll(/```json\n([\s\S]*?)```/g)]; + if (fences.length !== 1) { + at(`expected exactly one fenced json provenance block, found ${fences.length}`); + continue; + } + let body; + try { + body = JSON.parse(fences[0][1]); + } catch (e) { + at(`provenance block is not valid JSON: ${e.message}`); + continue; + } + checkProvenance(body.provenance, file, `claim "${id}"`); + } +} + +// --------------------------------------------------------------------------- +// The shipped curriculum, read as plain JSON, for cross-checks. +// --------------------------------------------------------------------------- +const skillIds = new Map(); // id -> file it was defined in +const prereqEdges = []; // { from, to, file } +const GRADE_BAND = /^(pk|k|[1-9]|1[0-2])$/; + +function readPack(file, { world }) { + const pack = readJson(file); + if (!pack) return; + if (typeof pack.domain !== 'string' || !pack.domain.trim()) fail(file, 'pack has no domain'); + if (!Array.isArray(pack.skills)) return fail(file, 'pack has no skills array'); + + if (world) checkProvenance(pack.provenance, file, 'pack'); + + for (const s of pack.skills) { + const at = (msg) => fail(file, `skill "${s?.id ?? '?'}": ${msg}`); + if (typeof s.id !== 'string' || !/^[a-z0-9][a-z0-9_]*$/.test(s.id)) { + at('skill id must be lowercase snake_case'); + continue; + } + if (skillIds.has(s.id)) at(`duplicate skill id, already defined in ${relative(ROOT, skillIds.get(s.id))}`); + skillIds.set(s.id, file); + + for (const p of s.prereq ?? []) prereqEdges.push({ from: p, to: s.id, file }); + for (const p of s.component_of ?? []) prereqEdges.push({ from: p, to: s.id, file }); + + if (!world) continue; // the shipped packs predate these rules; World proposals meet them + + if (typeof s.name !== 'string' || !s.name.trim()) at('skill has no name'); + if (typeof s.strand !== 'string' || !s.strand.trim()) at('skill has no strand'); + if (s.grade_band !== undefined && !GRADE_BAND.test(String(s.grade_band))) { + at(`grade_band ${JSON.stringify(s.grade_band)} is not a known band`); + } + // Assessable success criteria: a probe with a type is how this repository + // states "here is how you would know". A skill nobody can assess cannot + // enter the graph. + if (!s.probe || typeof s.probe !== 'object' || typeof s.probe.type !== 'string') { + at('every skill needs assessable success criteria: a probe with a type'); + } else { + checkPromptReadingLevel(s, file); + } + } +} + +// --------------------------------------------------------------------------- +// Reading level of child-facing prompt text. A grade 1 child cannot be probed +// with grade 6 prose. Flesch-Kincaid grade over the prompt, template +// placeholders stripped, against a cap for the skill's grade band. +// --------------------------------------------------------------------------- +function syllables(word) { + const w = word.toLowerCase().replace(/[^a-z]/g, ''); + if (!w) return 0; + const groups = w.replace(/e$/, '').match(/[aeiouy]+/g); + return Math.max(1, groups ? groups.length : 1); +} + +function fleschKincaidGrade(text) { + const clean = text.replace(/\{[^}]*\}/g, 'it'); + const sentences = Math.max(1, (clean.match(/[.!?]+/g) ?? []).length); + const words = clean.split(/\s+/).filter((w) => /[a-zA-Z]/.test(w)); + if (words.length === 0) return 0; + const syl = words.reduce((n, w) => n + syllables(w), 0); + return 0.39 * (words.length / sentences) + 11.8 * (syl / words.length) - 15.59; +} + +// A prompt may sit a little above the band, because an adult or the activity +// reads it aloud for the youngest bands. It may not sit years above. +const READING_CAP = { pk: 3, k: 3, 1: 3, 2: 4, 3: 5 }; +const capFor = (band) => READING_CAP[band] ?? Number(band) + 2; + +function checkPromptReadingLevel(skill, file) { + const texts = []; + for (const key of ['prompt', 'follow_up']) { + if (typeof skill.probe?.[key] === 'string') texts.push(skill.probe[key]); + } + for (const t of texts) { + const grade = fleschKincaidGrade(t); + const cap = capFor(skill.grade_band ?? 'k'); + if (grade > cap) { + fail( + file, + `skill "${skill.id}": prompt reads at grade ${grade.toFixed(1)}, above the ` + + `cap of ${cap} for band ${skill.grade_band ?? 'k'}: ${JSON.stringify(t)}`, + ); + } + } +} + +for (const file of readdirSync(join(ROOT, 'src', 'curriculum')).filter((f) => f.endsWith('.json'))) { + readPack(join(ROOT, 'src', 'curriculum', file), { world: false }); +} +for (const file of walk(join(WORLD, 'curriculum'))) { + if (file.endsWith('.json')) readPack(file, { world: true }); + else fail(file, 'curriculum proposals are JSON packs; unexpected file type'); +} + +// Unknown prerequisite references, then cycle detection over the whole graph. +for (const e of prereqEdges) { + if (!skillIds.has(e.from)) fail(e.file, `"${e.to}" depends on unknown skill "${e.from}"`); +} +{ + const next = new Map(); + for (const e of prereqEdges) { + if (!next.has(e.from)) next.set(e.from, []); + next.get(e.from).push(e.to); + } + const state = new Map(); + const stack = []; + const visit = (node) => { + const s = state.get(node) ?? 0; + if (s === 1) return [...stack.slice(stack.indexOf(node)), node]; + if (s === 2) return null; + state.set(node, 1); + stack.push(node); + for (const child of next.get(node) ?? []) { + const found = visit(child); + if (found) return found; + } + stack.pop(); + state.set(node, 2); + return null; + }; + for (const node of next.keys()) { + const cycle = visit(node); + if (cycle) { + fail(WORLD, `prerequisite cycle: ${cycle.join(' -> ')}`); + break; + } + } +} + +// --------------------------------------------------------------------------- +// Priors: parameters a family's fit will start from. Ranges mirror the +// clamps in src/domain/fit.ts, and guess plus slip must stay coherent. +// --------------------------------------------------------------------------- +for (const file of walk(join(WORLD, 'priors'))) { + if (!file.endsWith('.json')) { + fail(file, 'priors are JSON; unexpected file type'); + continue; + } + const doc = readJson(file); + if (!doc) continue; + checkProvenance(doc.provenance, file, 'priors'); + if (!doc.skill_types || typeof doc.skill_types !== 'object' || Object.keys(doc.skill_types).length === 0) { + fail(file, 'priors carry a non-empty skill_types object'); + continue; + } + const claimedProbeTypes = new Map(); + let defaults = 0; + for (const [name, t] of Object.entries(doc.skill_types)) { + const at = (msg) => fail(file, `skill_types.${name}: ${msg}`); + const inRange = (key, lo, hi) => { + if (typeof t[key] !== 'number' || t[key] < lo || t[key] > hi) { + at(`${key} must be a number in [${lo}, ${hi}], got ${JSON.stringify(t[key])}`); + return false; + } + return true; + }; + inRange('p_init', 0.001, 0.999); + const g = inRange('p_guess', 0.001, 0.5); + const s = inRange('p_slip', 0.001, 0.3); + inRange('p_learn', 0.05, 0.5); + inRange('half_life_days', 0.25, 365); + // Past this point BKT stops being identifiable and a wrong answer becomes + // evidence of knowing. fit.ts refuses such a fit; we refuse such a prior. + if (g && s && t.p_guess + t.p_slip >= 0.95) { + at(`incoherent: p_guess + p_slip = ${(t.p_guess + t.p_slip).toFixed(2)} >= 0.95`); + } + if (!Array.isArray(t.depends_on_claims) || t.depends_on_claims.length === 0) { + at('every prior links back to the claims it rests on via depends_on_claims'); + } else { + for (const ref of t.depends_on_claims) claimRefs.push({ ref, file, where: `skill_types.${name}` }); + } + if (!Array.isArray(t.probe_types) || t.probe_types.some((p) => typeof p !== 'string')) { + at('probe_types must be an array of strings'); + } else { + for (const p of t.probe_types) { + if (claimedProbeTypes.has(p)) at(`probe type "${p}" is already claimed by skill_types.${claimedProbeTypes.get(p)}`); + claimedProbeTypes.set(p, name); + } + } + if (t.default === true) defaults += 1; + } + if (defaults > 1) fail(file, 'at most one skill type may be the default'); +} + +// --------------------------------------------------------------------------- +// Lenses: mappings over existing skill ids. Never a new skill tree, never a +// change to the mastery model. +// --------------------------------------------------------------------------- +const MODEL_KEYS = ['prereq', 'component_of', 'p_init', 'p_guess', 'p_slip', 'p_learn']; +for (const file of walk(join(WORLD, 'lenses'))) { + if (!file.endsWith('.json')) { + fail(file, 'lenses are JSON; unexpected file type'); + continue; + } + const doc = readJson(file); + if (!doc) continue; + checkProvenance(doc.provenance, file, 'lens'); + if (typeof doc.lens !== 'string' || !doc.lens.trim()) fail(file, 'a lens names itself in a "lens" field'); + if (!Array.isArray(doc.mappings) || doc.mappings.length === 0) { + fail(file, 'a lens is a non-empty array of mappings over existing skill ids'); + continue; + } + for (const m of doc.mappings) { + if (typeof m.skill_id !== 'string' || !skillIds.has(m.skill_id)) { + fail(file, `lens maps unknown skill ${JSON.stringify(m.skill_id)}; a lens never invents skills`); + } + for (const key of MODEL_KEYS) { + if (key in m) fail(file, `lens mapping for "${m.skill_id}" carries "${key}"; a lens is a costume, never a model change`); + } + } +} + +// --------------------------------------------------------------------------- +// Trajectories: path metadata over existing skills. +// --------------------------------------------------------------------------- +for (const file of walk(join(WORLD, 'trajectories'))) { + if (!file.endsWith('.json')) { + fail(file, 'trajectories are JSON; unexpected file type'); + continue; + } + const doc = readJson(file); + if (!doc) continue; + checkProvenance(doc.provenance, file, 'trajectory'); + if (typeof doc.name !== 'string' || !doc.name.trim()) fail(file, 'a trajectory has a name'); + if (!Array.isArray(doc.leans_on) || doc.leans_on.length === 0) { + fail(file, 'a trajectory lists the existing skills it leans on'); + } else { + for (const id of doc.leans_on) { + if (!skillIds.has(id)) fail(file, `trajectory leans on unknown skill ${JSON.stringify(id)}`); + } + } +} + +// --------------------------------------------------------------------------- +// Cross-checks over the whole World. +// --------------------------------------------------------------------------- +for (const { ref, file, where } of claimRefs) { + if (!claimIds.has(ref)) { + fail(file, `${where}: depends_on_claims names "${ref}", which no claims file defines`); + } +} + +// No World data structure contains a learner id. The World knows the field; +// only the Record knows the child. Also pinned in test/invariants.test.ts. +for (const file of walk(WORLD)) { + const text = readFileSync(file, 'utf8'); + if (/\blrn_[a-z0-9]+/i.test(text) || /\blearner_id\b/.test(text)) { + fail(file, 'contains what looks like a learner id; The World must not know any child'); + } +} + +// --------------------------------------------------------------------------- +if (errors.length > 0) { + console.error(`world validation failed with ${errors.length} error(s):\n`); + for (const e of errors) console.error(` ${e}`); + process.exit(1); +} +console.log( + `world ok: ${claimIds.size} claims, ${skillIds.size} skills, ` + + `${prereqEdges.length} edges, version ${readFileSync(join(WORLD, 'WORLD_VERSION'), 'utf8').trim()}`, +); diff --git a/test/invariants.test.ts b/test/invariants.test.ts index 29b4619..c942d99 100644 --- a/test/invariants.test.ts +++ b/test/invariants.test.ts @@ -17,9 +17,10 @@ */ import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; const home = mkdtempSync(join(tmpdir(), 'primer-invariants-')); process.env.PRIMER_HOME = home; @@ -199,6 +200,75 @@ test('an activity that phones home or reports nothing is refused before it can b assert.equal(silent.ok, false, 'an activity that reports nothing teaches the record nothing'); }); +/** + * The World boundaries (docs/WORLD.md). The Record knows the child, The World + * knows the field, and the two must never mix: nothing in The World may know + * any child, and nothing in the pipeline that maintains The World may reach a + * family's record. These hold in both directions or the doctrine is decoration. + */ +const REPO = join(fileURLToPath(import.meta.url), '..', '..'); + +function filesUnder(dir: string): string[] { + if (!existsSync(dir)) return []; + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) out.push(...filesUnder(full)); + else out.push(full); + } + return out; +} + +test('no World data structure contains a learner id', () => { + // The World proposes for every child and targets none. A learner id inside + // world/ would mean shipped, public, versioned data knows a specific child, + // which is the one thing this architecture exists to make impossible. + const files = filesUnder(join(REPO, 'world')); + assert.ok(files.length > 0, 'world/ exists and ships content'); + for (const file of files) { + const text = readFileSync(file, 'utf8'); + for (const pattern of [/\blrn_[a-z0-9]+/i, /\blearner_id\b/]) { + assert.ok( + !pattern.test(text), + `${relative(REPO, file)} matches ${pattern}; The World must not know any child`, + ); + } + } +}); + +test('no pipeline code path can read a family record', () => { + // The maintainer-side pipeline (the validator, the watchers, the worktree + // hooks) operates on this repository and synthetic fixtures only. It has no + // business anywhere near a record: not the database module, not the record + // module, not the env vars that locate a family's file. A match here means + // a convenience import crossed the one boundary that must not blur. + const pipeline = [ + join(REPO, 'scripts', 'validate-world.mjs'), + ...filesUnder(join(REPO, 'automation')), + ...filesUnder(join(REPO, '.openclaw')), + ]; + assert.ok(pipeline.length >= 2, 'the pipeline surface exists'); + + const forbidden = [ + 'node:sqlite', // the record's storage engine + 'DatabaseSync', // the record's storage engine, unaliased + 'src/db', // the record's database module + 'src/record', // the record's domain module + 'PRIMER_DB', // the env var that locates a family's record + 'PRIMER_HOME', // the env var that locates everything private + 'record.db', // the file itself + ]; + for (const file of pipeline) { + const text = readFileSync(file, 'utf8'); + for (const needle of forbidden) { + assert.ok( + !text.includes(needle), + `${relative(REPO, file)} mentions "${needle}"; pipeline code must be unable to reach a family record`, + ); + } + } +}); + test('both prompts carry the engagement-mechanics ban and the limits of the loop', () => { // Prompt text is the soft layer above the structural enforcement, and it is // part of the contract too: the tutor is told what it must not build, and diff --git a/world/README.md b/world/README.md new file mode 100644 index 0000000..1f645cd --- /dev/null +++ b/world/README.md @@ -0,0 +1,22 @@ +# The World + +Versioned, reviewed, public data that ships inside primer releases. What it +is and why it exists is specified in `docs/WORLD.md`. The pipeline that +maintains it is specified in `docs/OPENCLAW.md`. + +Layout: + +- `claims/` holds pedagogy claims with citations, one claim per entry. +- `priors/` holds parameter priors derived from claims. They feed `primer fit` + as starting points. Fitted always beats prior. +- `curriculum/` is where proposed skill packs and edges land. They are + promoted into `src/curriculum/` after human review, never before. +- `lenses/` holds interest and career lenses: mappings over existing skill + ids, never new skill trees. +- `trajectories/` holds upper-grade path metadata. +- `WORLD_VERSION` is a single semver line, bumped on any admitted change. + +Every entry carries a provenance block, human-authored entries included. +`scripts/validate-world.mjs` enforces the mechanical rules and runs in CI. +Nothing in this directory targets an individual child, and nothing in it may +contain a learner id. The World proposes. The Record and the humans dispose. diff --git a/world/WORLD_VERSION b/world/WORLD_VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/world/WORLD_VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/world/claims/bkt-parameters.md b/world/claims/bkt-parameters.md new file mode 100644 index 0000000..2c9db5d --- /dev/null +++ b/world/claims/bkt-parameters.md @@ -0,0 +1,78 @@ +# Bayesian Knowledge Tracing parameters + +Claims about the BKT mastery model and its parameters. Same structure as +`spacing.md`: one claim per `##` entry, one sentence, strength, dependents, +and a fenced `json` provenance block. + +Citations below were verified against Crossref metadata on 2026-07-30 by +resolving each DOI. Verification of a citation is not review of a claim. + +## bkt/four-parameter-model + +**Claim.** Student knowledge of a skill can be tracked as a two-state model with init, learn, guess, and slip parameters updated from each observed response. + +**Strength.** strong. Corbett and Anderson (1995) introduced the model, and it has since been the standard baseline for mastery estimation in intelligent tutoring systems for three decades. + +**Depends on this.** `bktUpdate` and `DEFAULT_PARAMS` in `src/domain/bkt.ts`, the per-skill parameter columns in the schema, and the estimators in `src/domain/fit.ts`. + +```json +{ + "provenance": { + "sources": [ + "doi:10.1007/BF01099821" + ], + "retrieved": "2026-07-30", + "agreement": 1, + "review": "unreviewed", + "reviewer": null, + "depends_on_claims": [] + } +} +``` + +## bkt/guess-two-choice + +**Claim.** The guess parameter tracks the chance rate of the item's answer format, so a two-choice item has a guess rate near one half while a constructed response has one near zero. + +**Strength.** moderate. Corbett and Anderson (1995) treat guess as a per-skill parameter and cap it well below chance-plus-knowledge ambiguity; Baker, Corbett, and Aleven (2008) show guess and slip vary with context and that modeling that variation improves fit. The two papers share an author, so agreement is counted as one, and the specific two-choice value is arithmetic about the format, not a study result. + +**Depends on this.** The per-skill-type guess priors in `world/priors/bkt-priors.json`, and the `GUESS_CEILING` of 0.5 in `src/domain/fit.ts`. + +```json +{ + "provenance": { + "sources": [ + "doi:10.1007/BF01099821", + "doi:10.1007/978-3-540-69132-7_44" + ], + "retrieved": "2026-07-30", + "agreement": 1, + "review": "unreviewed", + "reviewer": null, + "depends_on_claims": [] + } +} +``` + +## bkt/identifiability + +**Claim.** BKT parameters are not identifiable from response data alone, and unconstrained fits can reach degenerate regions where a wrong answer raises the estimate that the student knows the skill. + +**Strength.** strong. Beck and Chang (2007) demonstrated that multiple parameter sets fit the same data with different mastery predictions, which is why every serious implementation constrains the parameter space. + +**Depends on this.** `fitParameters` in `src/domain/fit.ts` refuses to apply a fit when guess plus slip reaches 0.95, and `scripts/validate-world.mjs` applies the same coherence bound to proposed priors. + +```json +{ + "provenance": { + "sources": [ + "doi:10.1007/978-3-540-73078-1_17" + ], + "retrieved": "2026-07-30", + "agreement": 1, + "review": "unreviewed", + "reviewer": null, + "depends_on_claims": [] + } +} +``` diff --git a/world/claims/spacing.md b/world/claims/spacing.md new file mode 100644 index 0000000..b52eec5 --- /dev/null +++ b/world/claims/spacing.md @@ -0,0 +1,103 @@ +# Spacing and retrieval + +Claims about how practice timing shapes memory, and what in primer depends on +each. One claim per entry. Each entry is a `##` heading whose text is the +claim id, then the claim in one sentence, its strength, what depends on it, +and a fenced `json` provenance block. `scripts/validate-world.mjs` parses +this structure. Keep it. + +Citations below were verified against Crossref metadata on 2026-07-30 by +resolving each DOI. Verification of a citation is not review of a claim; +`review` stays `unreviewed` until a named human moves it. + +## spacing/spacing-improves-retention + +**Claim.** Practice distributed across time produces better delayed retention than the same amount of practice massed into one session. + +**Strength.** strong. Cepeda et al. (2006) is a meta-analysis of 317 experiments in verbal recall; the effect is large, robust, and one of the most replicated findings in learning science. + +**Depends on this.** `applyEvidence` in `src/domain/bkt.ts` grows the retention half-life as a function of the gap since last practice, not the count of repetitions. Twenty answers in one sitting are treated as one practice occasion. + +```json +{ + "provenance": { + "sources": [ + "doi:10.1037/0033-2909.132.3.354" + ], + "retrieved": "2026-07-30", + "agreement": 1, + "review": "unreviewed", + "reviewer": null, + "depends_on_claims": [] + } +} +``` + +## spacing/optimal-gap-grows + +**Claim.** The gap between practice sessions that best preserves memory grows with how long the material must be retained. + +**Strength.** strong. Cepeda et al. (2008) mapped the gap-by-retention-interval surface directly across 1,354 participants; the ridgeline of optimal gaps rises with the retention interval. Shares authors with the 2006 meta-analysis, so the two entries here are attested separately rather than counted as agreeing. + +**Depends on this.** The half-life gain in `src/domain/bkt.ts` is driven by the gap relative to the current half-life, so review intervals widen as retention strengthens. `nextDue` schedules the next encounter from the half-life against a target recall of 0.8. + +```json +{ + "provenance": { + "sources": [ + "doi:10.1111/j.1467-9280.2008.02209.x" + ], + "retrieved": "2026-07-30", + "agreement": 1, + "review": "unreviewed", + "reviewer": null, + "depends_on_claims": [] + } +} +``` + +## spacing/testing-effect + +**Claim.** Retrieving material from memory strengthens long-term retention more than restudying it for the same time. + +**Strength.** strong. Roediger and Karpicke (2006) showed tested prose passages beat restudied ones at a week despite losing at five minutes, and the finding has an extensive replication record. + +**Depends on this.** Probes are retrieval events, not presentations. Every activity must exercise a skill and report observations; `validateInterface` refuses an activity that records nothing. + +```json +{ + "provenance": { + "sources": [ + "doi:10.1111/j.1467-9280.2006.01693.x" + ], + "retrieved": "2026-07-30", + "agreement": 1, + "review": "unreviewed", + "reviewer": null, + "depends_on_claims": [] + } +} +``` + +## spacing/half-life-growth + +**Claim.** Recall probability after practice is well modeled as exponential decay with a half-life that grows when practice is successful and spaced. + +**Strength.** moderate. Settles and Meeder (2016) fit a half-life regression to millions of Duolingo practice traces and it outperformed fixed-interval baselines; the functional form is a workable engineering approximation rather than settled theory, and the population is adult language learners, not children. + +**Depends on this.** `retrievability` in `src/domain/bkt.ts` is the exponential decay, `half_life_days` in the mastery state is the parameter, and the 3.0-day initial value in the schema and in `world/priors/bkt-priors.json` is the prior this claim anchors. + +```json +{ + "provenance": { + "sources": [ + "doi:10.18653/v1/P16-1174" + ], + "retrieved": "2026-07-30", + "agreement": 1, + "review": "unreviewed", + "reviewer": null, + "depends_on_claims": [] + } +} +``` diff --git a/world/curriculum/.gitkeep b/world/curriculum/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/world/lenses/.gitkeep b/world/lenses/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/world/priors/bkt-priors.json b/world/priors/bkt-priors.json new file mode 100644 index 0000000..1416dcb --- /dev/null +++ b/world/priors/bkt-priors.json @@ -0,0 +1,62 @@ +{ + "id": "priors/bkt", + "description": "Per-skill-type BKT and forgetting-curve priors. Starting points for `primer fit`, grouped by the answer format of the skill's probe. A skill maps to the first type listing its probe type; anything unlisted falls to the default type.", + "note": "These are principled defaults informed by the claims they cite, not fitted values. Fitted always beats prior: `primer fit` computed from a family's own evidence overwrites these and nothing here overwrites a fit.", + "provenance": { + "sources": [ + "doi:10.1007/BF01099821", + "doi:10.18653/v1/P16-1174" + ], + "retrieved": "2026-07-30", + "agreement": 1, + "review": "unreviewed", + "reviewer": null, + "depends_on_claims": [ + "bkt/four-parameter-model", + "spacing/half-life-growth" + ] + }, + "skill_types": { + "two_choice": { + "probe_types": ["yes_no", "compare"], + "p_init": 0.15, + "p_guess": 0.45, + "p_slip": 0.1, + "p_learn": 0.2, + "half_life_days": 3.0, + "depends_on_claims": ["bkt/guess-two-choice", "bkt/identifiability", "spacing/half-life-growth"], + "rationale": "Two or three answers are on the table, so chance alone answers correctly near half the time. Guess sits just under the 0.5 ceiling that keeps the model identifiable." + }, + "selected_response": { + "probe_types": ["choose", "classify", "match", "sort", "odd_one_out", "identify"], + "p_init": 0.15, + "p_guess": 0.25, + "p_slip": 0.1, + "p_learn": 0.2, + "half_life_days": 3.0, + "depends_on_claims": ["bkt/guess-two-choice", "spacing/half-life-growth"], + "rationale": "A small set of options to select among. Chance rate near one in four." + }, + "performance": { + "probe_types": ["read_aloud", "timed_read", "retell", "read_then_retell", "discuss", "qa", "reason", "describe"], + "p_init": 0.15, + "p_guess": 0.1, + "p_slip": 0.15, + "p_learn": 0.2, + "half_life_days": 3.0, + "depends_on_claims": ["bkt/guess-two-choice", "spacing/half-life-growth"], + "rationale": "Extended performance scored against a rubric. Hard to pass by luck, but scoring noise and off days raise slip above the constructed-response prior." + }, + "constructed_response": { + "default": true, + "probe_types": [], + "p_init": 0.15, + "p_guess": 0.05, + "p_slip": 0.1, + "p_learn": 0.2, + "half_life_days": 3.0, + "depends_on_claims": ["bkt/guess-two-choice", "spacing/half-life-growth"], + "rationale": "The child produces the answer: arithmetic, writing, decoding, counting. Producing the right answer without the skill is rare, so guess sits near zero. This is the default for any probe type not listed above." + } + } +} diff --git a/world/trajectories/.gitkeep b/world/trajectories/.gitkeep new file mode 100644 index 0000000..e69de29