Learning Design Wiki — Agent Operating Guide¶
This is a persistent, LLM-maintained knowledge base for learning design. The wiki compiles design principles, instructional patterns, elements, strategies, theories, learner variables, and empirical claims into a structured, cross-linked reference.
You never write the wiki yourself. The LLM reads sources, ingests new content, cross-links pages, and keeps schemas consistent. You source materials and ask questions.
The wiki is a bundle in the Open Knowledge Format (OKF) v0.2: every content page is markdown with YAML frontmatter carrying type, title, description, status, generated, and (where applicable) sources; cross-links are plain bundle-relative markdown links, not Obsidian wikilinks; index.md and log.md are OKF's reserved directory-listing and change-log filenames.
Working alongside other sessions — read this before starting¶
This repo has one human maintainer and many Claude Code sessions — nine open at the time
of writing, each in its own worktree. A session that has been idle for a day still holds
its old plan and its old view of main, and resuming it will happily redo work that has
already landed. Sessions cannot message each other (an idle one is disconnected), so
this file is the coordination channel — it is loaded into every session's context
automatically. Anything a future session must not undo belongs here.
Keep the number of live sessions small. Most of the duplication below came from parallel sessions, not from parallel people. One session per open task, closed when the task merges.
Branch from main, merge to main the same day, never stack. A wiki-wide fix is a
wiki-wide diff: a typical task here changes 200+ files. Two branches living longer than a
day will collide on hundreds of them. This is not hypothetical — on 2026-08-31 three
branches were open at once, each changing ~200 of the same files, arranged in a stack
(normalize-slugs → fix-no-h1-pages → research-scraper-test-setup) whose base kept
moving. Two sessions independently fixed the same 13 corrupted pages, independently wrote
a link-repair tool, and independently shipped the same [^)\s]+ parenthesis bug in it.
One merge cost 41 conflict resolutions. Do not open a PR whose base is another feature
branch.
Before starting any wiki-wide pass, check whether it is already done. Run
python3 scripts/lint.py and python3 scripts/wiki_health_check.py --skip-doi first, and
git log --oneline -20 origin/main to see what landed recently. A count of zero means the
work is done, not that the check is broken.
Things that are settled — do not re-litigate or revert:
- Never assert a DOI that has not been resolved against Crossref, or, after a Crossref
404, against DataCite, with the same title check (added 2026-09-26). A DOI that resolves
to the wrong paper is worse than none, because it reads as verified.
10.1007/978-1-4684-7562-3_3("Model of Causality in Social Learning Theory") was auto-applied to 69 pages as Bandura (1977); it took a manual audit to catch.cc.titles_align()guards the containment case that let it through, andenrich.verify_page_citations()gates every newly written page. classify_doi'serrorstatus is notwrong_paper. A failed lookup means Crossref was unreachable. Stripping onerrordeletes good DOIs from every page touched during an outage.- No content page is
status: draftany more (maintainer, 2026-10-15: "we've moved past that stage"). Every page was promoted toreview, and ingest, settling and enrichment now writereview.draftis kept only for a genuine skeleton that a CSV import orenrich.pystub has not yet filled.reviewmeans model-written and not checked by a person;stablestill needs averified:entry. Lint's DOI check skips drafts, so with no drafts left it applies to every claim. - Links to filenames containing parentheses need the
<...>form. A bare destination closes at the first).scripts/fix_links.py --applyrepairs them; lint'slink_needs_angle_bracketscatches new ones. consent.basis: licencemust keep its five conditions, and condition 4 above all. A licence basis satisfies the research layer's AI-processing check in place of a participant disclosure, which is correct for secondary data — but it is the only value in that schema that relaxes a check, so it is the only one that can be abused. It requires a named licence with its grantor, a non-emptysecondary_use_caveats,ai_processing_disclosed: not-applicable,data.identifiability.collectedofdeidentifiedoraggregate, and (added 2026-09-15)sources_uniformly_governed: true. The fourth is what stops it becoming a one-line escape hatch: it is whylazuli-platform-telemetrycannot reach the path however its consent block is written. Simplifying these conditions away would silently re-open the check the layer exists to enforce. The fifth stops one licensed corpus buying the relaxation for every other source in a protocol that governs several — declaringsources_uniformly_governed: falseis honest and costs the relaxation, which is the intended outcome, and the fix for a protocol needing both is to split it. Reasoning inresearch/SCHEMA.md("Secondary data" and "One value, several instruments"), mutation list — now six, all of which must break the benchmark release — in the fixture header atresearch/protocols/example-open-corpus-secondary-use/1.0.0.yaml.-
scripts/find_title_duplicates.pylists only pairs no judge has decided (2026-10-20). A pair two models judged different (eval/duplicates/<kind>.ndjson,eval/claim-merges/decisions.ndjson, both committed) is not listed again;--include-judgedshows them. Every batch judges its new pairs, so the count stays near zero. It is reported, not lint-failing, on purpose. -
Embargoed research never enters this repository, on any branch. learning-wiki is public: a pushed branch or an opened PR is visible to anyone, merged or not, so "keep it in an unmerged PR" does not keep anything private. Embargoed work lives in the private
Learning-engineering-researchrepo until the embargo lifts, and only then is it written here. The private repo stamps embargoed files with the markerLDA-EMBARGO.scripts/check_embargo.pyrefuses to let it out, before the push (CI runs too late, since the branch is already public by then): a PreToolUse hook in.claude/settings.jsonblocksgit pushand the GitHub MCP write tools in every Claude Code session here.git config core.hooksPath scripts/hooksdoes the same for a person pushing by hand, andlint.py --type embargois the CI backstop. A marker catches copying, not prose retyped from memory, so an agent with the private repo attached must still treat anything it read there as embargoed unless told otherwise. -
No LDA research is published in this wiki yet, and none may be drafted here.
research/holds synthetic scaffolding only. The one real governance file,lazuli-platform-telemetry(which forbids publication), moved on 2026-09-24 to the private repo's transplant tree,Learning-engineering-research:research/wiki/research/ protocols/lazuli-platform-telemetry/1.0.0.yaml, byte-for-byte; the mentions of it below still describe it correctly, but it is not in this repo. A finding arrives only once it is public, by the route inresearch/PUBLISHING.md: a protocol permitting publication, a plan that predates the release, and a DOI reserved before the release file is frozen (scripts/mint_release_doi.py --reserve). Only ahuman:actor may--publish, which is irreversible.release.doiis refused on any non-researchrelease, because a DOI is exactly the badge that makes a simulation read as a finding. -
The repo root is public-facing: nothing but the wiki's own files lives there. On 2026-09-25 it carried 22 run logs and one-off reports (
glm-*.log,doi-report-*.md,health-final.md,health-truly-final.md), the first thing a visitor saw on GitHub. They were removed (git history keeps them) and.gitignorenow refuses them. Write run output toeval/runs/, which is ignored; never commit a log or a scratch report at the root. -
A pattern is a reusable, research-based design, general across domains and contexts (maintainer, 2026-09-30). A course for a particular setting, such as a self-paced mobile language course or a clinical onboarding programme, is a design that uses the patterns whose evidence fits its learning goals and learners. It is never a pattern page. Context (mobile, no teacher, 10 minutes a day) is a requirement or constraint a pattern's page speaks to, and a design scenario is at most an entry under that pattern's
## Examples. Do not create context- or domain-specific patterns, and do not report "no pattern for this course" as a gap. Somepatterns/pages were designs in this sense (the Duolingo pages,kindergarten-balance-scale-equal-sign-lesson-sequence,tutor-led-strategies-adult-language-lessons,elderly-refugee-programs-beyond-language-learning, among others, now indesigns/). Designs live indesigns/(maintainer, 2026-10-02), a kind of their own, withid:like the other identified kinds: the triaged ones move there frompatterns/, and Lazuli-generated design descriptions are added there. A design page says which patterns it uses and for what (## Patterns Used); the evidence stays on the patterns and claims it links. -
Products and programmes live in
products/, and education research methods inresearch-methods/(maintainer, 2026-10-10). A product is a named thing others adopt or use: software or a platform, a curriculum or programme package, an assessment or measurement instrument, a dataset, a branded intervention or initiative. One programme's own frameworks, indicators, tools and theories of change are listed on its page under## Components, not written as theories or elements. A design is one setting's design (a course, a lesson sequence, one site's implementation); a branded programme many sites adopt is a product. A research method is a method for studying learning or evaluating education that is specific to education or especially useful there (cluster-randomised trials of schools, evidence standards, knowledge tracing, growth and value-added models). General social-science methods are not pages (maintainer: "regular old social science research methods are not that useful in a database like this").methods/stays the design methods. Design-based research is a design process (processes/design-based-research), not a research method (maintainer, 2026-10-20).research-methods/is secondary and may eventually go away, so where a page could sit in either, it belongs with the design side. A review's or meta-analysis's set of included studies is not a product ("corpus of 36 studies", "meta-analytic dataset"): its findings are claims. A named dataset others reuse is.lint.py'smisfiled_kindfails on either kind of misfiling, andsettle_candidates.pyrefuses both (2026-10-21).
When you finish something wiki-wide, add a dated entry at the top of
history/agent-log.md, newest first, as every session before you
did; that file is how the next session finds out what happened and why. If the work changes
a standing rule, change it below too. This file holds the rules; the history holds the
reasons and the numbers (moved there 2026-10-09, when this file had reached 272 KB).
Standing rules from the history (read the dated entry before changing one)¶
The site and the generated files (2026-10-09):
- The docs site has two tiers. mkdocs renders principles, patterns, designs, processes,
methods, learner variables and the elements and theories marked
canonical: true;scripts/build_longtail.pyrenders claims, strategies and the other elements and theories as light pages at the same URLs, with letter-paged listings. Pagefind indexes both.scripts/build_site.shis the one build, used by all three workflows;docs_hooks/two_tier.pysays which page is in which tier. PR previews run only for PRs that change the site build (or carry thepreviewlabel). - Pagefind is pinned at 1.5.2 with its binary (
pagefind-bin-extended): 1.3.0 silently dropped results for any word at an index-chunk boundary. The search page lists Main pages (canonical, then other curated pages,tierfilter, re-ranked by title and alias coverage) above the full results, turns off Pagefind's long-page penalty, and every page has a header search box. - Deploys replace the published tree (
keep_files: false), carrying open PR previews over, so renamed and deleted pages do not accumulate. At about 21 KB a page (measured 2026-10-12), the 1 GB Pages limit is near 48,000 pages; past that the long tail has to move off GitHub Pages. log.mdholds the current month;okf_lib.rotate_log()moves earlier months tolog/YYYY-MM.md. The log is not on the site.wiki-index.jsonandreverse-index.jsonare written one record per line (okf_lib.dump_json_records). Change their shape only additively. No copy of the index lives anywhere else (checked 2026-10-20: not in the Lazuli app, not in learning-design-spec, whose docs describe a platform copy that was never committed). Its readers read this repo directly: Learning-engineering-research'swiki_agent.pyreadspagesfrom a local checkout, and the spec'scheck-wiki-links.pychecks page URLs. So there is nothing to keep in sync.canonical: truemarks the pages the candidate ledger settles against (settle_candidates.py --mark-canonical), and every learner-variable page. Search ranks them first when a query names them.situation-index.jsonis generated from every## Fitting the design to a situationtable (build_situation_index.py, run bybuild_indexes.py,--checkin CI). It feeds the MCPsituationstool and the site's "Design for a Situation" pages. Write a row's situation with theevidence-dimensions.jsontags (`novice`,`online-self-paced`,`procedure`) where one fits: a row named only in words is found by a few conservative patterns or not at all.python3 scripts/eval_search.pyscores search againsteval/search/designer-queries.json; run it before and after a ranking change, and add the query a change is meant to fix.
Batches (scripts/run_scrape_batch.py):
- Curated lists come first (maintainer, 2026-10-07): the Learning Agency hub queue, then
the AIED knowledge base (
scripts/eval/source_lists.pywrites both toeval/runs/queues/, ignored; the hub has no licence, so none of its data is committed). Run--queue <files> --take N --pmc 0 --eric 0 --arxiv 0. Unfetchable entries go toeval/runs/queues/fetch-failed.ndjson. Campbell reviews are fetched by their PMC copy (--campbell), DSpace repositories through their REST API. - Batches write no principle, pattern, element or theory page. Ingest puts each in
eval/candidates/candidates.ndjson, andsettle_candidates.py --apply(run in every batch) attaches it to a canonical page, joins it to another candidate, writes a design page, writes it to its product's page (product, grouped by name: a programme's tools and frameworks become components of one page) or a research-method page, or drops it. The oldartifactoutcome is no longer given, and its candidates are asked again. Promotion to a new canonical page is an agent's job, checked byscripts/check_design_page.py. Do not re-enable--direct-pages. A cluster promotes on two independent sources: different articles with no author in common and not published by the same organisation (maintainer, 2026-10-10: one author or organisation across years is a research agenda, not confirmation;settle_candidates.independent), or on one synthesis at q3 or above. - Pages from before the two kinds were re-filed once (
scripts/sweep_kinds.py, 2026-10-10): 1,323 to products, 222 to research methods, each move confirmed by a second model. Its 390 plain folds into canonical pages are ineval/candidates/sweep-kinds.ndjsonand were not applied (too many loose matches); apply them only after review, never with a bare--from ... --apply. - A claim needs a DOI, an evaluation report, or a design page that leans on it (maintainer, 2026-10-15:
"quality over quantity"). A claim whose evidence has no DOI after a Crossref and DataCite search is removed,
unless it rests on a report from a named evaluation organisation (WWC/IES/REL, Mathematica, MDRC, AIR, RAND,
Abt, SRI, WestEd, Westat, Child Trends, the UChicago Consortium, NWEA, LPI, Urban Institute, CRESST, CREDO;
maintainer: "evaluation reports are useful") or a principle, pattern or 10+ design pages cite it. A kept
claim of the last kind carries a
> **Evidence quality** ·line saying what it rests on. New sources come from syntheses and fromchase_citations.py, not from ERIC or arXiv topic search. - A claim is kept when a design decision could change because of it (maintainer, 2026-10-13).
That covers evidence about a design choice, the reliability of a component a design relies on
(knowledge-tracing accuracy for an adaptive course, essay-scoring or AI-detector accuracy), and
context that informs a design (needs, learner populations, baselines for reading an effect size).
1,959 context claims removed on a misreading of the maintainer were restored the same day; claims
are re-sorted on this test (
eval/claim-sort/,sort_claims.py --all). Research-practice claims are removed where both models agree (maintainer, 2026-10-16: "we don't need research methods studies that don't directly impact design decisions"); a disputed sort stays. Local, dated context still goes to the maintainer as removal candidates.scripts/remove_pages.pydoes a removal. - Claims about a learner characteristic link into its
learner-variables/page and back (scripts/link_learner_variables.py,--newin every batch): learning-design-spec's learner dimensions reach the research only through those pages. The model names the role (predictor, moderator, outcome) and a marker capped at the claim's evidence; the claim gets a## Learner Variablessection. - Group categories are never written as causes (maintainer, 2026-10-16: "race is not a contributor to
learning outcomes but does correlate with other factors"). On
race-and-ethnicity,genderandsocioeconomic-status, a claim is linked as "outcomes differ by group: a gap reflecting circumstances and treatment, not a cause" (link_learner_variables.ROLE_TEXT_BY_VARIABLE), and each page opens with a How to read this page statement saying so. Learning-variable headers count "show a difference / mixed / no difference", never "for / against" (evidence_rollup.POLARITY_LABELS).learning-style-preferencesopens with its bottom line: matching instruction to style is not supported; single studies listed there are not tests of matching. - The coded evidence cells are committed (
eval/runs/evidence-axes/cells/andcells-contrasts.json; maintainer, 2026-10-13), the one folder undereval/runs/that is: every evidence map and report reads them, and the ignored store vanished with each container.code_evidence_axes.pyrefuses structurally broken replies, so a bad cell is retried, not stored. The store is sharded by the first two characters of the claim slug (cells/sp.ndjson; maintainer, 2026-10-24: "easiest to search and maintain as it grows"), read and written only throughscripts/evidence_cells.py. Do not gzip it or rejoin it into one file: the single file reached 53 MB against GitHub's 100 MB limit, and a compressed file is a whole new blob in history on every batch. - A fresh container needs
pip install -r requirements-eval.txt(without pypdf most PDFs fail). - After a container restart the proxy port changes; a process started before it keeps the
old
HTTPS_PROXYand every request fails. Compare/proc/<pid>/environwith the current value, kill and--resume. - Every batch cleans up after itself (2026-10-20, after an overnight wiki-wide cleanup the maintainer
did not want to repeat).
run_scrape_batch.pynow, for the batch's own pages: links its new claims into concept pages (link_pages.py --orphan-claims --only, verified); merges claim pairs two models judge the same finding (judge_claim_merges.py --judge --titles, then--apply); judges new same-folder title pairs (judge_duplicates.py, only pairs no judge has decided); links inline author-year citations (link_inline_citations.py); re-renders the study tables; and judges every claim three or more design pages cite (check_load_bearing.py --top 5000, only changed entries are paid for). What a model cannot settle stops the run for a person or agent: a load-bearing failure, a new citation problem, an unmarked claim citation, ani1–i3with no printed statistic, an unjudged cross-folder collision. Each of those health counts was taken to zero on 2026-10-20 and must stay there. - Do not commit hand-written pages while a batch is running, and check a fresh-container batch's claim diff before committing: a step whose correctness depends on an ignored cache is a bug.
CURRENTis prompt v136. No provider pin; leaveOPENROUTER_MAX_COMPLETION_PRICEandOPENROUTER_QUANTIZATIONSunset.:batchmodels are refused by the account's zero-data-retention setting. Costs recorded before 2026-09-28 are list-price estimates.- Topic search (
--pmc,--eric,--topics-file) adds volume, not decisions; prefer named syntheses and the curated lists.
Evidence on claim pages:
- Every entry carries
q(design tier),i(impact),n, and a kind with its rigour (qualitative · r3), judged within the kind (eval/kind-rigor/RUBRIC.md) and never from the wiki's own paraphrase.code_kind_rigour.py --newruns in every batch. - An
i1–i3must rest on a printed effect size for that finding; otherwisei?(recode_impact.py).i?is never 0. - An evidence entry opens with its citation line; the tools take its first link as the resource.
- Count distinct studies, never claims; no verdicts or averaged
iin roll-ups. - A field added to the codes line must be left out of every digest that keys a verdict
(
check_load_bearing.units()). - Rigour is never re-judged in a merge or cleanup from the entry text.
- The load-bearing check (
check_load_bearing.py, runbookeval/load-bearing/README.md) runs in every batch; read a judge's failure before acting on it, and--dismissa checked one. - Markers are capped by the claim's recorded evidence (
strength_cap).
Principle and pattern pages (conditional-model format, principle-pattern-authoring.md;
waves 1–7 in eval/page-triage/):
- Every principle and pattern page is brought up in one loop (maintainer, 2026-10-14; they are what a
course design names).
scripts/upgrade_worklist.pygives each page its step: search (a canonical page on two studies or fewer: unlinked wiki claims vialink_pages.py --pages ... --claims-only --verify, thenfind_page_syntheses.py, which names published work and keeps only what Crossref confirms, and a batch on its queue), fold (still thin after the search: into the parentchoose_fold_parent.pyfinds, two models agreeing or a third settling, withmerge_pages.py; maintainer: placeholders "that haven't really held up to the research"), convert (three studies or more: the conditional format below), fragment (not canonical: folds into its canonical page) and reread (converted, with more than ten unread further-evidence bullets). Do not convert a thin page instead of folding it. - Where no claim tests the page's relationship, keep a labelled default design with doses and a progression across sessions; a rewrite that leaves only caveats loses.
## Fitting the design to a situationrows: take the brief's own categories, routine and privacy terms as given; when a page loses a test, look first for the missing setting row.- A page stating an absence of claims says it is an absence in the wiki, not in the literature.
- Keep every claim the old page cited; keep the old body in a
<!-- deprecated -->block. Links inside HTML comments count for nothing. - Folds and merges:
merge_pages.py(across kinds withkind/slug, no alias) andmerge_claims.py(keeps a differing write-up of a shared study in a<!-- merged -->comment). After merging claims rundedupe_claim_bullets.py --apply: a page that cited both lists the kept claim twice in a section, under the two old titles (judge_claim_merges.pyruns it itself). Which page survives is the maintainer's call when it is not obvious.
Citations:
- Crossref (and DataCite after a Crossref 404) is reachable from this sandbox; check once
per task. Identity is checked on first author and year as well as title
(
citation_identity.identity_mismatch); a foreign-language registry title passes only with author, year, journal and a coordinate (translated_record). - Never fill a DOI by majority;
resolve_doi_conflicts.py --applyneeds--allow-cluster-rewrite. Runverify_citation_edits.pyafter any citation tool writes. - Never branch a run off whatever branch is checked out:
git checkout main && git pullfirst.
Housekeeping:
- Branch lists in prose go stale; run the command. Every remote ref against
main:for b in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin); do echo "$(git rev-list --count origin/main..$b) $b"; done | sort -rn - If something merged and the site does not show it, check the
Deploy Docsrun first. - Third-party Actions are pinned to commit SHAs; resolve a new tag with
git ls-remote. python3 scripts/wiki_health_check.pyis the general health report;scripts/health_evidence.pyits evidence section.
Known open work¶
Kept in history/agent-log.md with the state notes it
refers to. The parts still open as of 2026-10-09: the sweep of older pages onto current
conventions (priority_worklist.py, check_evidence_markers.py), the citation backlogs
(check_citations.py and its --collisions, --metadata, --titles, --variants modes;
citation_worklist.py for the book backlog), and the maintainer decisions listed in the
2026-10-09 history entry; the kinds and the independence rule were settled 2026-10-10.
Research observations — the structured record beside a claim¶
A claim page argues something. observations/<study-key>.yaml records the configuration
that argument rests on, so a study can eventually be represented as
intervention/configuration + population + context + comparison + measurement + time
→ observed outcome/effect
and so that d = .78 stays one measurement of one configuration rather than becoming a
weight between two concepts. Two studies of "retrieval practice" with different learners,
implementations, outcomes or horizons are two records and must stay two records.
observations/SCHEMA.md is the field reference; scripts/observation_lib.py carries the
rationale. What belongs here:
The schema is at version 2, and the change was forced by evidence rather than foreseen.
v1 had two levels — a study whose population/context/intervention were assumed
SINGULAR, and observations under it. That middle assumption is a primary-study
assumption, and it broke in nine places the moment a meta-analysis and a three-arm trial
were put through it: 23 studies and 3371 participants had one integer slot between them;
k differed per pooled estimate (9 for knowledge, 2 for surgical skills, from a corpus of
23) so it could not live on the study; three arms had one intervention: field; and a
comprehension ANOVA on 62 of 67 randomised had no home for its own analysed n. So there
are three levels now — source, evidence base, observations — and the middle
one carries arms.
The governing rule, which explains every placement in the schema: put information at
the lowest level at which it actually varies, and preserve observations at the grain at
which the source actually reports them. Total corpus N varies per study; analysed N per
result; k per pooled estimate; arms and comparisons per contrast. And a pooled estimate
over heterogeneous comparators stays ONE observation, because that is the grain the source
reports — the heterogeneity is recorded structurally, never normalised into effects nobody
measured.
Arms + comparisons is the abstraction that makes one schema fit both shapes. A
three-arm trial's "unenhanced vs baseline" and a meta-analysis's "spaced online vs massed
online" are the same object: a contrast between two named configurations. The synthesis
pools it across studies; the trial measures it once. Nothing about a meta-analysis needed
a parallel set of fields — it needed the middle layer to stop assuming one group of
people. An arm carries no role. The first draft gave it one and it was removed on evidence:
nothing read it, and it is not a property of an arm — in the three-arm fixture
unenhanced-elaboration is declared intervention and is the reference side of
enhanced-vs-unenhanced, so the role varies by comparison, which is where
index_arm/reference_arm already say it. Nor does an arm need a type to mark a
synthesis: the source already says design.family: meta-analysis and the arm's own
size: {value: 17, unit: studies} says it is studies rather than people. What replaced
role is the invariant it was standing in for without enforcing — every arm must be
named by at least one comparison, because an arm nothing is contrasted with is
unreachable from any observation. A single-group study has one arm; a survey has
arms: [].
Either side of a comparison may name more than one arm, which is the whole
representation of a heterogeneous comparator. Martinengo et al. pool three studies against
two different controls and report one estimate, so it stays one observation; the compiler
emits reference_is_heterogeneous: true beside the resolved arms. Per-study contribution
counts are deliberately not recorded — the source reports them, but no use case needs that
grain yet, and a field nobody reads goes stale.
A cluster design needed four fields and no new level, which is the strongest evidence so
far that the abstraction holds. evidence_base.allocation records the unit RANDOMISED
where it differs from the unit analysed — 20 classes against 370 students, with every group
contrast carrying df = 16 rather than 369, so reading size as the effective sample
overstates precision by the design effect. result.clustering holds the ICCs and design
effects, as a list with a required level, and deliberately NOT folded into
heterogeneity: I² is between-study variation of effects, an ICC is within-study
correlation among observations sharing a cluster, and pooling the two would be pooling
nonsense. result.power records reported power with kind required, because a post-hoc
0.44 is what separates "no effect" from "no detection". And comparisons[].contrast carries
weights where contrasts are not pairwise — Helmert H1 sets control against the mean of
three arms, which the arm lists already handled.
A network meta-analysis broke the one rule that had held through every other shape:
comparison_ref was required on every observation. A SUCRA ranks one model within a
network and rests on no contrast, so an observation now names exactly one of
comparison_ref or arm_ref. With it came result.interval_type (a Bayesian credible
interval is not a confidence interval, and unlabelled bounds say nothing about which),
comparisons[].evidence_route (in a network an estimate may rest on no head-to-head study
at all), and result.publication_bias — an assessment RUN AND FOUND, as distinct from
attempted_but_precluded, which is one that could not be run. And the schema caught itself:
result.k was required on every pooled effect until an NMA put per-contrast counts in a
figure, leaving only "invent a k" or "break the schema". observability.pooled_k restores
the absent-versus-unreported distinction to the rule that had forgotten it.
A single-case design needed the SUBJECT to have a name. In a group design the people
are a sample and only their count matters; in a multiple baseline each participant is a
separate replication, the findings differ by person, and "three of the four maintained" is
unreadable without knowing which one did not — it was Serena. evidence_base.subjects and
observations[].subject_ref, symmetric with arms, absent for every group design. With it:
tau_u (single-case designs use non-overlap statistics, and its siblings are not added
until something needs one) and result.estimate_range with a required over — Spencer &
Kirby report a Tau-U range of 0.64–1.06 across four participants and no pooled estimate
anywhere, so the alternatives were to invent a midpoint or drop the effect. The phases
are the arms: baseline, intervention-with-icons and icons-removed are three named
configurations and a phase change is a within-subject contrast.
All three predicted-hard shapes now encode, and arms + comparisons never moved. Nine studies, 36 observations: four second-language primary studies, a meta-analysis, a three-arm trial, a four-arm cluster-randomised trial, a Bayesian network meta-analysis and a multiple-baseline single-case design. Every addition across the three sat at the level the thing actually varies at, which is the governing rule doing its job rather than being quoted at one.
Every count carries its unit, and the corpus proved that necessary before either fixture
was written. Its evidence entries already carry n=18, n=30 studies, n=66 articles,
n=N/A and n=large (aggregated) — one field doing four jobs, so nothing can read it.
A count here is {value, unit} with unit required.
There are four epistemic states, not two. Absent (nobody looked); unreported
(somebody read the source and it does not say); attempted and precluded (the authors
tried the analysis and could not finish it, with the reason); and measured-as-null.
Martinengo et al. attempted subgroup analyses and publication-bias assessment and were
precluded by the number of studies and their heterogeneity — unreported would lose the
reason and absent would lose the attempt, so study.synthesis.attempted_but_precluded
holds it.
appears_in is optional in v2. Requiring it made the evidence layer depend on the
argument layer: a structured record of a real study is valid evidence whether or not
anyone has yet written a claim that appeals to it. The ratchet stays — a listed claim
and anchor must still resolve — and check_observations.py --summary counts records
nothing cites rather than letting orphans go unnoticed.
The record is NOT in the claim's frontmatter, and that was decided by reading the code.
sources[] is derived: sync_evidence_codes.py --apply rebuilds the whole block from the
body's ## Evidence through dump_frontmatter, which emits exactly
id/resource/title/author/q/i/n — so anything else nested there is destroyed on the next
run of a script that is already in run_scrape_batch.py's unattended chain. And
check_source_entry_keys holds a closed key set at four-space indent precisely so a
rewrite landing on a YAML key is caught; a new key there is indistinguishable from that
damage. Third reason, from the data: a study is cited by many claims, so a per-claim copy
is the drift shape this repo has already lost weeks to on DOIs.
No existing claim page changed. The join runs from the record: appears_in names the
claim slug and the ### Author Year anchor, and lint.py --type observations fails if
either stops resolving. A rename breaks loudly instead of orphaning the record silently.
Three distinctions the schema exists to hold, and none of them may be collapsed:
- Absent ≠ unreported ≠ zero. An omitted field means "not established";
observability.<field>: unreportedmeans "somebody read the source and it does not say". A consumer reading a missing effect size as zero drags every pooled estimate toward nothing. Same discipline ascrossref_reachable: falsevsflagged,"doi": nullvs an absentdoi, andclassify_doi'serrorvswrong_paper. - A capability is not a valued outcome.
outcome.rolerecords which a dependent variable appears to be andoutcome.role_basisrecords whether the source said so or you inferred it;outcome.source_languagekeeps the author's wording regardless.outcome.valued_byis populated only where the source identifies who values an outcome — a researcher measuring graduation does not establish that anybody values it. - A research observation is not a design hypothesis. A logic model says "Goal A is
intended to contribute to Outcome B"; a record here says "Study S observed relationship R
between configuration A and Outcome B under population/context C". There is deliberately
no field in which to write that one becomes the other, and every compiled record carries
kind: research_observationandprovenance.source_typeso a published finding stays distinguishable from runtime learner data, a synthetic simulation, an LLM-proposed relationship, or a later platform experiment.
Never infer a stronger design than the source states. quasi-experimental is what "the
sample was divided into two groups" means; an article calling for randomised trials as
future work has not run one. moderators.reported requires its own source_quote — a
plausible explanation goes in candidate, and promoting one is the same act as inventing
a DOI.
Never convert a metric during ingestion. A partial η² stays a partial η²; turning
β = 17.17 words into a d needs a pooled SD, and inventing one to make records comparable
is how a store fills with confident untraceable numbers. compile_observations.py
therefore does no pooling, no averaging and no conversion — comparability is a separate
analytical step run explicitly downstream. A prediction interval is not a confidence
interval and must never be derived from one: the CI is about the mean effect, the PI
about the next study, so result.prediction_interval.source is required and a computed
one can never be read as a reported one.
python3 scripts/check_observations.py # validate; exit 1 on any problem
python3 scripts/check_observations.py --summary # what is in the store, by shape
python3 scripts/check_observations.py --stub <claim> # skeleton for a claim, TODOs and all
python3 scripts/compile_observations.py # research_observation NDJSON
python3 scripts/compile_observations.py --explain <study-key>/<obs-id>
--explain is the acceptance test run as a command: what exactly was done, to whom,
compared with what, in what context, measured how, at what time, with what result and
uncertainty, and what important information was not reported — answered from the record
alone, with no narrative prose read.
Migration is deliberately not a batch job, and nothing has been migrated. Six studies
are recorded as fixtures — four second-language primary studies, one meta-analysis
(martinengo-2024) and one three-arm randomised experiment
(jemr-lexical-elaboration-2026), 21 observations in all. 424 claim pages have no record
and are not broken by that, because an absent record is a legal state and
check_observations only validates the files that exist. It starts and stays green,
so the property this file leans on — that a count of zero means the work is done rather
than the check being broken — survives.
The four v1 fixtures were converted to v2 and the conversion was verified, not eyeballed:
every page's observations, study and appears_in block parses byte-identical before
and after, and population/context are preserved with only n moved into
evidence_base.size. That is the same discipline the 115-page frontmatter repair used —
"the parsed value is unchanged except where I intended it to change" is checkable, and
reading a diff at that size is not.
The route for the rest, when the structure has proved itself:
--stub <claim-slug>fills in what is genuinely mechanical (citation, DOI, the claim/anchor join, an ASCII-folded study key) and marks everything elseTODO. The validator refuses the file until the TODOs are gone, so the cheap half is automated and the half that needs the article is blocked rather than guessed.- Rank by reuse, not by slug order. A study cited by 30 claims repays a record 30
times;
citation_worklist.pyalready ranks by exactly that and is the right reading end. - Prefer the source over the page. Two of the four fixtures were built from publisher
full text, one from an abstract, one only from the wiki's own evidence entry — and
provenance.extraction_methodsays which, per file, because the three are not equally good. Building a record from a paraphrase of a paraphrase is how the fabrications this file catalogues got in. - Automated extraction is built end to end, and not switched on.
prompt_versions/v129.txtasks for one v2-shapedstudy_recordper article (arms, comparisons, observations, each observation linked to its claim's evidence entry with abearing), plusarms[].deliveryanddesign.preregistration.ingest_extractions.build_study_recordturns it intoobservations/<key>.yamlonly ifobservation_lib.validate_recordpasses against the live claim index. It never overwrites an existing record and never defaults abearing, and it sets an anchor only on a claim page the same run wrote. The extraction validator reports a bad record as warnings, so a flawed record never costs an article its claims but does show in the tuning signal.v127(a v1-shaped block that nothing ingested) is superseded.CURRENTpointed atv99until 2026-09-27 and is nowv135. Moving it is the eval ratchet's call, not a side effect: tune fromv128(the null-impact rule only) orv129(plus the record), and letoptimizedecide.
One fixture corrected the claim page it came from, which is the point of doing this.
claims/game-based-practice-outperforms-traditional-l2-vocabulary-instruction.md renders
Frolli et al. as finding Italian articles "disproportionately hard for this group". The
paper reports articles as an instructional accommodation — one example ("for instance") of
complex topics taught with occasional Russian, because articles are absent from Russian and
Ukrainian grammar — and
measured no article-specific outcome at all. The observation file records it as an
implementation note and a candidate moderator, never a reported one. Structuring a study
is a re-read of it, and a re-read finds things.
The research layer — where knowledge came from¶
research/ records what happened and how it can be interrogated; the wiki's
kinds record what we currently believe. Full field reference in
research/SCHEMA.md; rationale in scripts/research_lib.py.
Protocol ──> Study ──> Research Release ──> Evidence ──> Claim
^
Review ──┴── Issue
There is no evidence/ folder, and that is the main decision. observations/
already is the evidence layer — its study: block is "the SOURCE", its
observations are the individual results with their arms, comparisons, samples and
observability. A second Evidence object would hold the same fields about the same
results, which is the drift shape this repo has lost weeks to on DOIs. So a release
names its findings (evidence: [{ref: <study-key>/<observation-id>}]) and each
record names the release back (study.release: {ref, version}). Both halves are
required to agree: a half-written edge reads as a working link from whichever side
you arrive on, and only a reader who checks the other side ever finds out.
Three additive fields in observations/, and one that became required.
study.release (replacing citation when the source is ours — a release already
carries title, authors, version and date), observations[].analysis_ref (which
declared analysis produced this result), and appears_in[].bearing —
supports / contradicts / qualifies, required and never defaulted, because
the direction is a property of the edge: one result supports one claim and
qualifies another. The four existing records were updated; the schema stays at
version 2, so an older file fails loudly on the missing bearing rather than being
read wrongly.
appears_in[].anchor is now optional, and that is the separation between the two
layers. A record may name the proposition it bears on before anyone has written it
into that claim's argument. Promoting evidence into a claim's ## Evidence section
is an editorial act and stays one — do not automate epistemic promotion. An
anchor, when given, is still ratcheted: a rename fails lint rather than orphaning
the record silently.
Immutability is structural, not a promise. A version is a file —
research/releases/<id>/<version>.yaml — so publishing 1.1.0 writes a new file and
cannot touch 1.0.0. Which version is current is derived (highest semver present)
and never stated, because stating it would mean editing an older file to say it is
no longer current. The cost is real: 1.1.0 repeats almost everything from 1.0.0,
because a version has to stand alone to be citable on its own.
A protocol is a configuration file, not a checkbox — and it records governance, it
does not constitute it. external_review.status is a five-value vocabulary
(approved / exempt / not-required / not-determined / pending) because
"an authority determined it exempt" and "we reasoned none applies" are different
claims by different parties. It is deliberately not spelled yes/no: YAML 1.1 reads
a bare yes as the boolean True, which broke the first draft of the fixture.
Three enforcement checks are implemented, narrowly, to prove the arrow
machine-readable protocol → automated checking is real: a dataset may not be
more identifiable than its protocol permits at its access class; an analysis
declaring ai_processing: true requires consent.ai_processing_disclosed: true
(unspecified fails as well as false — silence is not permission); and a
release requires permitted_uses['research-publication'] == permitted on its
protocol, because a release IS a publication. All three were verified by mutating
the fixture until they fired.
consent.permitted_uses is a mapping of closed classes, not a list of
sentences, and the first real protocol forced that on day one: platform
telemetry collected under terms of service, with no research consent step, may
legitimately validate a pipeline and may not be published as research. As
prose that line is unenforceable; as classes it is a release being refused. An
absent class means unspecified, so absence is safe and growing the vocabulary
cannot invalidate a frozen protocol file. secondary_research_use was a separate
flag and is now refused by name — it is
permitted_uses: {secondary-research-by-others: ...} said twice.
Review is a structured contribution, not a comment, and never evidence.
assertion and rationale are required and the target must resolve, so a bare
"interesting study" or an insult with no assertion never becomes an object —
which is what makes open review expensive to spam without gating it. No
institution, rank or degree is required, requested, or able to gate whether a
contribution exists. What differs between contributions is author.basis — what
grounds this contribution, a property of the contribution and not of the person.
The schema refuses positive, approved, helpful_votes, votes, score,
reputation, credentials and bearing by name, each with its reason, enforced
rather than requested. A review carrying bearing: is rejected because
supports/contradicts/qualifies already exists on the evidence→claim edge; a review
may introduce evidence, it is not evidence. assessment: records checkable
properties of form (specific, reasoned, evidence supplied, independently
reproduced, duplicate-of) — form is what automation may judge, and truth is not.
Moderation is conduct, never correctness. moderation: is a list (a history
that can be overwritten is not a record), and the reason codes are a closed set of
conduct categories with no code meaning "wrong" or "disagrees with the authors" —
so burying a critique on the merits requires inventing a code the schema does not
have, and fails. withheld changes prominence, never existence.
Issue is the sixth object, and it earns its place. Without it, eighteen people
independently noticing one defect produce eighteen unlinked reviews: nothing can
tell they are one problem, prominence has to be computed from volume, and the first
person to say it is indistinguishable from the seventeenth. raised_by is ordered by
first appearance and append-only. A resolved issue keeps every review that raised it
and every review that disputed it; resolution.decided_by is required because a
resolution is a position held by somebody, not a verdict issued by a platform.
A contributor's history must stay derivable and must never be stored. "Eleven
concerns later confirmed, two not supported" is a traversal of
author.id → reviews → issues → resolution. That is why reputation is a refused
field.
The first real protocol exists, and its central statement is a refusal. (It now lives in
the private Learning-engineering-research repo, not here; see the settled items above.)
lazuli-platform-telemetry/1.0.0.yaml records the governance
of Lazuli platform learning telemetry as it stands: learners accepted terms of
service, nobody asked them to consent to research use, so the data may build and
validate the observation pipeline and may not be published as research —
permitted_uses: {research-publication: prohibited}. Naming it from a release
fails validation, verified. Every field was established by reading
learning-engine-ai-frontend (schema.ts, learning-graph/compile/runtime.py,
report/gate.py, sdk/capture-consent.ts) and every field nobody has checked
says unspecified or NOT ESTABLISHED — retention, the storage controls, the
jurisdiction, the deidentification key holder. Do not fill those in by
assumption; each is a question for a person with access to the hosting
configuration, and a guess there reads as verified. A protocol permitting
publication is a separate future protocol needing a consent mechanism that does
not exist, not a version bump of this one.
Two schema changes were forced by writing it, both the same shape as
permitted_uses. data.storage's three controls are now tri-state — a
bare boolean gave an author who had not checked the hosting config only "claim
true" or "claim false", and both assert something about an unexamined system.
And participants.age_assurance.{method, jurisdiction} is now required: an
inclusion criterion of "18+" does not say whether anyone checked, and
jurisdiction rides with it because the operative consent age is set per member
state, so a band without one decides nothing.
The pipeline reached the same rule independently, at row grain.
experiments/learning-graph/report/gate.py in learning-engine-ai-frontend
refuses publication from the compiled rows; this layer refuses a release from the
protocol. Both are needed — a protocol can permit what the rows cannot
support, and vice versa.
The two vocabulary mismatches are settled, and only one of them was a
collision. pseudonymised was: one concept, two spellings, a join that breaks
silently. This layer moved to the pipeline's spelling — ~21 occurrences in
unmerged files here against 16 across six files there including a published JSON
schema, eight test assertions and compiled Parquet, and GDPR's own term of art is
"pseudonymisation" (Art 4(5)). Conceding on your own side is not the same act as
changing the other repo by fiat. Do not rename it back.
The two source_type fields were never a collision, and this file said
otherwise for an hour. They answer different questions: the pipeline's says what
produced a row (human/synthetic/research/reconstructed), this layer's
says what kind of record it is
(research/runtime-learner-data/synthetic-simulation/llm-proposed/platform-experiment).
Only research is shared, and human maps to either runtime-learner-data or
platform-experiment depending on whether the release assigned conditions —
which a row cannot know, because that is a property of the investigation.
Renaming either side would destroy information. The identifiability scales
only partly overlap for the same reason: identifiable (could be) is not
identified (is), and anonymous is not deidentified.
So the crosswalk is code. python3 scripts/check_research.py --crosswalk
prints it and exits 1 the moment the other side grows a value nothing maps —
a crosswalk in prose rots. Where the scales differ the mapping is deliberately
conservative, resolving toward the more identifiable reading, because it feeds a
publication ceiling and the safe error is refusing a release that could have been
allowed; None means the row does not determine this rather than a guess.
One synthetic fixture, marked at every level (source_type:
synthetic-simulation, SYNTHETIC in every title and header comment): a protocol,
two release versions, two evidence records, five reviews, one issue —
and claims/spaced-practice-improves-long-term-retention.md is unchanged (it has since been merged into
spaced-practice-improves-retention, which answers to the old slug as an alias). The
evidence names it with a bearing and no anchor, so the edge exists and nothing
fabricated appears in a real claim's ## Evidence section.
python3 scripts/check_research.py # validate; exit 1 on a problem
python3 scripts/check_research.py --summary # what is in the layer
python3 scripts/check_research.py --issues # issues, state and provenance
python3 scripts/check_research.py --why <claim-slug> # the traversal
python3 scripts/lint.py --type research # the same checks, in CI
The study plan is the fifth object, and it is what a reviewer reads¶
research/studies/<id>/<version>.yaml sits between the protocol and the release:
Protocol → Study → Release → Evidence → Claim.
It is not a release, and that was forced rather than chosen. A release IS a
publication — the layer refuses one whose protocol lacks
permitted_uses['research-publication'] == permitted. A plan is not. Folding them
together would either make it impossible to plan a study under a protocol that forbids
publishing (exactly the internal pipeline-validation case lazuli-platform-telemetry
describes) or weaken the publication gate. It is also the object an IRB actually
reviews: they review a plan, not a paper.
That reasoning was tested, not assumed, and a parallel branch was rebuilt onto it.
Another session had put pre-registration on the release as a stage: preregistration
field. Planning an internal validation study under lazuli-platform-telemetry fails —
"this release publishes findings, but its protocol's consent records
permitted_uses['research-publication'] as 'prohibited'" — so the study is unplannable,
and the message asserts it publishes findings about a file containing none. Do not
reintroduce a stage: on the release. The two objects are not interchangeable and the
publication gate is what the layer exists to enforce.
A primary endpoint requires prespecified_prediction: {prediction,
disconfirming_result}. measure says what is measured and analysis_plan.tests says
how it is analysed; neither says what result would count AGAINST the hypothesis, and
without that any outcome can be narrated afterwards as a success. Optional on secondary
and safety; refused on exploratory — an endpoint with a prediction is not
exploratory, and carrying both is how an exploratory result is reported later as though it
had been predicted. There is deliberately no mode: confirmatory | exploratory field:
role already draws that line, and a second field saying the same thing is the drift shape
this repo has lost weeks to on DOIs.
A release names the study it reports — study: {ref, version}, or
no_study_reason: for a secondary analysis that genuinely had no plan of ours. The
docstring claimed this edge from the start and nothing implemented it, so a plan and its
report were two unconnected files. The plan's effective_from is checked against the
release's released_at: a plan written after its own findings plans nothing, and only the
dates reveal it.
analyses[].prespecified is required and never defaulted, and false is an ordinary
honest value — a reviewer's question answered after the fact is good practice; what would
not be is that analysis appearing with nothing to say it arrived late.
analyses[].endpoint_refs is declared, never inferred, and is a LIST. The first draft
of --deviations matched endpoint names against analysis titles by word overlap and
immediately reported an endpoint as unreported on a release that reports it — a fuzzy join
that is wrong is worse than none, because the output looks like a finding. Where no
analysis declares one, --deviations says endpoint coverage is not checkable rather than
guessing. A list because one analysis routinely answers several endpoints.
python3 scripts/check_research.py --deviations <study-id> # plan vs report
python3 scripts/check_research.py --stub-study <study-id> # a plan skeleton
--deviations reports rather than judges. The spaced-review fixture's 1.1.0 adds
time-on-task-model to answer a reviewer's issue, and that is what should happen.
A TODO anywhere in an observations/ or research/ file now fails validation, comments
included. --stub promised this and delivered two-thirds: a marker in an enum or a date
failed on its own shape, but one in free text validated clean — verified by injecting one
into outcome.source_language and watching the whole store report zero. On a plan, free text
is where disconfirming_result lives; a skeleton committable with its prediction still
unwritten looks registered and is not.
Dates are validated as dates, after a draft protocol with effective_from: "NOT
ESTABLISHED" validated clean. And PyYAML's timestamp constructor raises a bare ValueError
on an impossible date, which crashed both checkers with a traceback instead of reporting one
file; both loaders now catch it.
The study's own enrolment.special_populations was never shape-checked — only the
protocol's — so a scalar there crashed check_study_against_protocol with an
AttributeError. Found by typing special_populations: none, which reads as a perfectly
reasonable thing to write. It is a mapping per class, because the protocol ceiling is checked
per class and "which ones" is what a scalar cannot answer.
What lives on the study is what varies per study — the governing rule observations/
established. A protocol has no single question, no enrolment target and no endpoint,
because it governs a family. So question, background, design, setting,
resources, enrolment, timelines, procedures, endpoints, analysis_plan,
benefits, participant_burden, results_sharing and community_involvement are the
study's. Counts are {value, unit}: in a cluster design the number randomised and the
number analysed are different numbers of different things.
A declared endpoint is not a measured one. endpoints[] says what the study set out
to test; observations/ records what was measured. The difference between the two lists
is what preregistration exists to police, and it is only visible because they are
separate records. At least one endpoint must be role: primary.
conformance is machine-authored; determination is not, and they are separate
blocks. A machine may compute that a plan satisfies a ruleset — naming the ruleset and
version, or the result cannot be re-checked or superseded. A machine may never record
that an IRB determined anything: determined_by must be human:<id> or org:<id>, the
same discipline that makes append_authority() refuse a non-human verifier. And
conformance.result must agree with its own checks — a conforming stamped over a
failing check fails validation, because that summary is exactly what a reviewer would
rely on.
status changes are version bumps, because the file is immutable. That is the
feature: a material change writes a new version, which invalidates the conformance result
computed against the old one. That is the escalation mechanism, not a workaround.
Three enforcement checks, protocol as ceiling and study as actual, each verified by
mutating the fixture until it fired: a study may not enrol a special population its
protocol does not permit (not-addressed is not permission); consent required by the
protocol cannot be documented as none without a recorded waiver; and a study enrolling
minors requires the protocol's consent.minors block.
Everything added to protocol is OPTIONAL, and that is forced by immutability — a
newly required field would retroactively invalidate every frozen protocol. New:
participants.special_populations, consent.process, consent.waiver (the five
regulatory criteria as separate checkable fields), consent.minors,
consent.withdrawal.investigator_initiated, data.subject_privacy, per-risk
probability/magnitude/duration/reversibility, and sites.
What optionality buys is expressibility, not enforcement. Before these fields,
"nobody established this" could not be said — the question had no home. The three
pre-existing protocols went from 13–14 unanswerable sections to 0–1 without a single
frozen file being touched; their gaps became unspecified rather than no-field.
data.subject_privacy is the one most easily got wrong. A reviewer's template is
explicit that it is not storage: it is intrusiveness — how subjects are approached, how
they are put at ease, and how the team is entitled to reach any source of information
about them. Answering it from the storage and access fields answers a different question
convincingly, which is worse than not answering it.
The synthetic fixture pair exercises the minors cluster on purpose, because minors are
the population an automated review pathway is least likely to be able to cover:
protocols/example-classroom-consented-study/1.0.0.yaml and
studies/example-retrieval-practice-classroom/1.0.0.yaml, marked synthetic-simulation
and SYNTHETIC throughout. Together they render every section of a reviewer's template:
26 filled, 4 not-applicable, 0 gaps.
Two YAML traps bit while writing them, both the same shape. A list item reading
- Primary: arm coefficient… is a mapping, not a string, because of the :. The
validator caught both (must be a non-empty string) — but a schema that accepted free
-form values would have stored a dict where a sentence belonged and nothing would have
said so.
Rendering a protocol as somebody else's form¶
scripts/render_brany_protocol.py renders a protocol (optionally with a release) as
BRANY's SBER protocol template — the 26-section document an independent IRB would expect.
It exists to answer one question checkably: which of an IRB's questions can this schema
already answer, and which can it not?
python3 scripts/render_brany_protocol.py <protocol-id> [--release <id>] # the document
python3 scripts/render_brany_protocol.py <protocol-id> --coverage # status per section
python3 scripts/render_brany_protocol.py <protocol-id> --gaps # just what it cannot answer
A section the schema cannot answer renders as a loud marker, never as prose, and that
is the whole discipline: a governance document filled in by inference reads as verified,
which is the failure this file catalogues at length on DOIs. Five statuses, and the
distinctions carry the meaning — unspecified (the schema has the field; this protocol
has not established it) is not no-field (the schema has nowhere to put it), exactly as
crossref_reachable: false is not flagged.
Some "not applicable" determinations are made by the record rather than by the author.
BRANY requires a safety-monitoring plan and injury-compensation terms only above minimal
risk, so risks.classification decides sections 16 and 23 — and not-determined correctly
yields neither answer, because an undetermined classification decides nothing. That is a
small worked example of the larger argument: a machine-readable protocol lets a reviewer's
own rule run against the configuration.
Those gaps are closed, and the measurement is the record of it. When this was first
run, 13 of 30 sections could not be answered at all (14 where risk classification was
undetermined). The study object and the optional protocol fields above close every one:
a protocol and a study plan together render 26 filled, 4 not-applicable, 0 gaps. The
three frozen protocols, untouched, went from 13–14 unanswerable to 0–1 — their gaps are
now unspecified, which is a statement, where before there was no field to leave empty.
Re-measure rather than trusting that paragraph:
python3 scripts/render_brany_protocol.py example-classroom-consented-study \
--study example-retrieval-practice-classroom --coverage
The one section that still reported no-field did so correctly: section 23 on
lazuli-platform-telemetry (now in the private repo), whose risks.classification is not-determined, so the rule
that would make it inapplicable cannot fire. That is the record refusing to decide, not a
missing field.
--why is the acceptance test run as a command: why does the wiki believe claim X,
answered by walking claim ← evidence ← analysis ← dataset ← release ← protocol from
the records alone, printing what is contested at every level, and naming any record
whose source_type is not research loudly — so a simulation, runtime telemetry or
a model's proposal is never mistaken for a published finding.
These are not wiki pages, deliberately: no type: frontmatter, no banner, no
entry in wiki-index.json, the mkdocs nav or okf_lib.CONTENT_FOLDERS. Unattended
batch tools iterate the content folders and rewrite what they find, and an immutable
versioned object must not live where a batch job rewrites things. How a release is
rendered is a separate problem and out of scope.
research/SCHEMA.md ends with the questions left deliberately unresolved — whether
Analysis and Dataset become first-class objects, where deviations: belongs,
whether ModerationDecision earns its own file, and the review-type vocabulary.
Do not resolve them by guessing; resolve them when there are real releases.
Page identity — id: and aliases:¶
The learning-design-spec
pipeline resolves design documents against this wiki: a pattern plan names
element: <slug>, a principle names Realizes: <principle-slug>, a design section cites
research:<claim-slug>, and spec/learners.md requires every learner dimension to be a
learner-variables/ slug. Its contract (that repo's findings/0008) opens with every
page's id is stable and unique within its kind — because a rename breaks a course that
has already shipped, silently, outside this repo.
So the six kinds a design document can point at carry id: equal to their filename:
elements, principles, patterns, claims, learner-variables, strategies. Theories stay
out — nothing in the spec addresses one. A page type gains an id when something outside
the wiki depends on pointing at it.
Strategies were nearly left out, and that would have been wrong. findings/0008's
contract names only claim, pattern, element and principle slugs, and the reverse index
reaches strategies without anyone naming them — so the first cut excluded all 2,557.
spec/patterns.md then makes a phase name one outright:
phases:
- phase: Read the case
element: case-based-learning
strategy: chunked-reading-with-embedded-questions # a learning-wiki strategy slug
whose ### Instructions become that phase's authored brief. So a strategy rename breaks a
shipped course exactly the way an element rename does.
python3 scripts/page_identity.py --check— report;--apply— stamp missing idspython3 scripts/lint.py --type identity— id present, equal to the slug, and unique across ids and aliases within a kind
The claims id: was repurposed, deliberately. CLAUDE.md used to document it as a short
programmatic code (we-4, fi-2). Measured before the change: present on all 422 pages and
useless as identity — 56 blank, only 183 of the 366 non-empty equal to the slug, and six
values shared by two pages each. enrich.py stamped an empty id: into every new claim,
which is where the blanks came from. The short code was referenced nowhere in the wiki's
12,893 citations while the design spec addresses claims by slug throughout, so it was
vestigial.
aliases: is what makes a rename non-breaking, and it is never stamped empty. It
appears when a page is actually renamed: update_links_for_renames.py sets id: to the new
slug and records the old one in aliases:, so a course document written against the old
name still resolves. Empty fields on a thousand pages train a reader to skip the block.
Ids and aliases share one namespace per kind. A document saying element: foo cannot tell
whether foo is a current slug or a retired one, so two pages answering to it is ambiguous
however that came about — which is the case lint catches that filename-uniqueness never
could, filenames being unique by construction.
A page moved to another kind keeps its old id through moved_from: (2026-10-20). Aliases live
inside one kind, so a page folded or re-filed into another kind (a programme filed as a strategy,
now a product) left strategy: good-behavior-game resolving to nothing. The page it went to lists
the old kind-qualified ids, moved_from: [strategy/good-behavior-game]; build_wiki_index.py
publishes them as a separate moved map in wiki-index.json ({old kind: {old slug: "new kind/new
slug"}}, additive, so resolve keeps its shape), and the MCP resolve tool follows it.
merge_pages.py writes it on every cross-kind fold. 1,570 retired ids were backfilled from git
history and the merged-from comments; a page deliberately removed is never given a redirect.
Both YAML spellings are read (aliases: [a, b] and the block form), because both are
written here — page_identity.py edits raw frontmatter text inline, while
ingest_extractions.py builds pages through okf_lib.dump_frontmatter, which emits a
block. A reader understanding only one would see no aliases on half the pages that have
them: the rename would look recorded and still not resolve.
The spec reads exactly two things from wiki frontmatter: id and type:. Everything
else it resolves is a body section — ### Instructions for a phase's brief,
### Target Learners and ### Target Learning Goals for matching personas and goals,
#### Requirements/#### Constraints as the applicability filter, ## Claims with their
inline markers, ## Examples on a learner-variable. Those are at 100% and ~99.8% already,
and findings/0008 is explicit that they must stay prose: structuring them into YAML would
lose the qualifications that make them useful. So there is no further frontmatter work for
the pattern-building process — a pattern doc's pedagogy: block and a pack's
elements: live in those documents, not here.
Note: verify_citation_edits.py will trip on an id/alias pass — those are frontmatter
edits, not citation edits, and it is a guard for citation tool runs only.
The reverse index¶
reverse-index.json answers "which pages point at this one". It is generated by
scripts/build_reverse_index.py and regenerated by build_indexes.py, so anything that
adds or relinks a page already refreshes it; --check fails when the committed copy is
stale.
It exists because the forward links from a principle are thin and the reverse links to it are not:
| edge | forward | reverse |
|---|---|---|
| principle ↔ strategy | 43 / 198 (22%) | 1752 / 2557 (69%) |
| element ↔ strategy | — | 2390 / 2557 (93%) |
| claim ↔ strategy | — | 2324 / 2557 (91%) |
Three times the coverage, and the more trustworthy direction: the author of a strategy knew
which principle they were applying, while the author of a principle page is guessing at
which strategies might exemplify it. So do not hand-populate ## Examples on 198
principle pages — one pass over the corpus gets more, and gets it right.
Claim edges carry the citation's polarity and strength from the inline marker, which is
what lets the design side weight evidence per citation rather than per claim.
It is committed on purpose. CI on the design-spec side has no wiki checkout, so an index that only exists after someone runs a script is an index that side cannot use.
The wiki index¶
wiki-index.json is the table the learning-design-spec
side resolves against: one record per content page — id, type, title,
description, path, status, aliases, and grain_size where a page carries one —
plus a precomputed resolve map per kind. scripts/build_wiki_index.py writes it and
build_indexes.py regenerates it alongside the reverse index; --check fails when the
committed copy is stale, and CI runs that check on every PR.
It exists because that repo shape-checks realizes: and cannot resolve it. A profile
carries realizes: <pattern-slug> and a method realizes: <strategy-slug>, with the kind
fixed by what carries the field; the same repo also resolves element:, research:<claim-slug>
and spec/learners.md's learner-variable slugs. Its CI has no wiki checkout — that is a
deliberate constraint on that side, not an oversight — so a typo'd slug passes CI, ships,
and fails in front of a course author. A committed table is what closes that, the same
arrangement and the same reason as reverse-index.json.
Aliases are the half a naive dump would omit and the half that matters. A rename is
non-breaking only if the retired slug still resolves, and this file is where the spec side
learns that 4cid means 4cid-four-component-instructional-design. Ship an index of
current slugs only and every rename becomes a silent break one repo over — the exact
failure aliases: exists to prevent, reintroduced at the boundary.
The resolve map is derivable and is emitted anyway. Ids and aliases share one
namespace per kind, which is a rule a second implementation can get wrong; emitting the
resolution once means every consumer resolves identically, and a collision is caught at
build time by the side that can fix it. build_wiki_index.py refuses to write a table
that resolves one slug two ways rather than letting a consumer pick whichever it saw last.
Both halves come out of one pass, so they cannot drift from each other. Theories are
included even though nothing in the spec addresses one: a consumer filters on type for
free, and a kind left out is a kind nobody can point at until somebody notices.
The MCP server — the same data, as tools¶
scripts/mcp_server.py serves the wiki over the Model Context Protocol, so an agent can query it
without knowing how to read the index files. It has six read-only tools. search and fetch are
in the shape deep-research clients expect, and ids are <kind>/<slug>. resolve handles current
and retired slugs, and returns every kind a slug lives in. backlinks comes from the reverse index
and carries each claim citation's [±~][SMW] marker. why is check_research.why() with its
output captured, so the tool and the command cannot disagree. situations answers a learner and
context analysis: given tags from evidence-dimensions.json (novice, online-self-paced,
procedure, ...) or words, it returns the rows of every page's ## Fitting the design to a
situation table that speak to them, each with its basis, from situation-index.json
(scripts/build_situation_index.py, regenerated by build_indexes.py, --check in CI).
python3 scripts/mcp_server.py # stdio
python3 scripts/mcp_server.py --http 8765 # stateless HTTP, 127.0.0.1:8765/mcp
python3 scripts/mcp_server.py --call search '{"query": "retrieval practice"}'
claude mcp add learning-wiki -- python3 /path/to/learning-wiki/scripts/mcp_server.py
It depends on the standard library and PyYAML only. Both transports were tested against the
official MCP Python SDK's client (2.2.0). Every page result carries status and trust_tier,
and the server's instructions say the wiki is model-written. No page has a verified: entry, so
an outside agent would otherwise take a fluent page for a checked one. The shape is borrowed from the
Renaissance AI and Education Resource Hub's worker; no code was copied, because that repo has no
licence yet. It is not hosted anywhere. It runs from a checkout, and nothing in it is newer than
that checkout. Hosting it is a separate decision: GitHub Pages cannot run it.
Two kinds for the designer's side: processes/ and methods/¶
Every other kind in this wiki describes instruction a learner meets. A pattern is a
reusable design at lesson or unit level; a strategy is a concrete activity recipe. Design
work is a different object entirely — what somebody does to produce the instruction —
and it had been filed among them: ADDIE and design thinking sat in patterns/ beside
cooperative learning, and task analysis sat in strategies/ beside think-pair-share,
which advertises a designer's elicitation method as a teaching activity.
processes/— whole design processes a designer works through: ADDIE and the Dick & Carey systems approach, SAM, design thinking, LXD, Understanding by Design, continuous improvement, faculty course design, Data Wise, humanizing co-design.methods/— the practices a process is made of: needs analysis, learner and context analysis, task analysis, learning-hierarchy task analysis, cognitive task analysis, learner personas, activity-system personas, backward design, standards crosswalk, family-interview assessment task design, making power dynamics visible.
The two levels are not cosmetic — they mirror the spec's own split. In learning-design-spec a profile realizes a whole process and a method realizes a practice, and the kind is fixed by what carries the field. One combined folder would have resolved both against one namespace, and nothing would catch a profile that named a crosswalk. Two kinds keep that error catchable.
realizes: therefore resolves against process and method now, not pattern and
strategy. That is a change to the spec repo's decisions/0012, and it is not
alias-able: aliases live inside one kind, so a retired kind cannot be aliased. Both
sides have to move together, and wiki-index.json carries the resolve map for both new
kinds so the spec side can do it in one edit.
The same slug in two kinds is fine and now happens on purpose. design-thinking is a
process (the designer's cycle) and a strategy (students running the cycle on an
authentic problem) — two genuinely different objects that share a name. Ids are unique
per kind, so each resolves in its own namespace. The strategy page was briefly
deprecated when the process needed the pattern namespace; it is restored.
The criterion is function inside learning design, not the discipline a method came from.
Settled when stakeholder-mapping, logic-model and theory-of-change landed: all three were
developed outside instructional design — in strategic management and in programme evaluation — and
all three belong here, because a method is defined by the job it does in a design process, and each
of them produces a named design artifact that a process profile requires. "Was it invented for
instructional design" is the wrong question and would have excluded backward design's own ancestry.
Note the asymmetry that keeps this from swallowing everything: the test is what the method produces
in a design, so a general technique that produces nothing a design carries is still out.
What stayed put, and why, because these are the judgement calls:
strategies/task_analysis.md(underscore) is learners breaking an assignment into sub-tasks — a different practice frommethods/task-analysis.md, not a duplicate of it, despite near-identical titles.patterns/online-course-design.md,storyline-science-curriculum-designandinterdisciplinary-course-based-research-experiencedescribe the shape of the resulting course, not the steps to produce one. Patterns.patterns/professional-development.mdis instruction whose learners are teachers.strategies/exemplar-analysis.mdis learners studying worked examples.
Three are genuinely unresolved — flagged rather than moved:
strategies/formative-evaluation.mdis classroom formative assessment, but instructional design uses the same words for testing draft materials with learners (one-to-one, small group, field trial). The process pages needed the second sense and four links were quietly asserting it; they were removed rather than left pointing at a page that says something else.methods/formative-evaluation-of-materialsis a real gap.- The student-shadowing family (~10 near-identical pages) is educator-as-investigator
work and belongs in
methods/, but moving one of ten fragments the set. It is part of the near-duplicate backlog and should move as a group once deduplicated. cede-power-student-voiceandstudent_feedback_on_the_classsit on the line between a design stance and a teaching practice. Left as strategies.
Adding a kind touched fourteen lists, which is thirteen too many. Not a figure of
speech: the folder list was hard-coded in fourteen places, and lint.py's own copy had
never contained learner-variables — so for as long as that folder has existed no page
in it was checked for a broken link, and the "N pages indexed" line was quietly twelve
short. It reported OK because it never looked. The copies that mean "every content folder"
now derive from okf_lib.CONTENT_FOLDERS; the ones carrying per-kind data (labels,
singulars, index config) must stay hand-written, so check_nav_coverage fails when a
content folder is missing from any of them, naming the specific consequence.
Page templates: a process uses the Pattern template (it has a Sequence, Requirements
and Constraints, a grain size) and a method uses the Strategy template (it has
Instructions). Those section names are deliberately unchanged — ### Instructions,
### Target Learners, ### Target Learning Goals, #### Requirements/#### Constraints
are what the spec reads out of the body, and renaming them to suit the new folder would
break the thing the new folder exists to serve. Only ## Related Patterns /
## Related Strategies become ## Related Processes / ## Related Methods, which
nothing reads.
Evidence markers are the evidence layer¶
The [±~][SMW] marker beside a claim link is the one field the design-spec pipeline reads
for evidence quality. It beats the frontmatter evidence_strength: in a way worth naming:
strength attaches to the citation, not the claim, so a claim cited [+S] on one page
and [~W] on another records a disagreement one number per claim would flatten. Polarity
carries as much as strength — a principle citing [-S] cites strong evidence against,
which is a constraint on the principle, not support for it.
python3 scripts/check_evidence_markers.py reports citations without one: 244 across 196
pages, out of 11,697 outside claims/ — 97.9% already carry one. Links from claims/ are
excluded, because a claim linking a claim is a ## Related Claims relation rather than a
citation (247 of the 491 raw unmarked links are that).
It is a script, not a lint check, and deliberately so. Adding it to lint.py would take
it from 0 to permanently red, destroying the property this file leans on — that a count of
zero means the work is done rather than the check being broken. find_title_duplicates.py
sets the precedent. And the backlog cannot be cleared mechanically: a marker asserts a
polarity and a strength about how this page uses this claim, so deriving it from the
claim's frontmatter, or copying the majority marker other pages used, would manufacture
exactly the kind of plausible unverified detail this repo already lost weeks to on DOIs.
Do not clean up evidence_strength:. 19 distinct values across 422 claims, 117 blank,
nine of them spellings of "not yet". The inline notation supersedes it and the design side
does not read it.
Three core operations¶
1. Ingest¶
Process a new source (paper, book chapter, CSV batch, worked example) into wiki pages.
Steps:
1. Identify the page type(s) the source contributes to (principle, element, pattern, strategy, theory, learner-variable, claim). learner-variable is schema-ready but not yet part of the automated single-pass extraction prompt (deliberately deferred to a dedicated future sweep, so the extraction agent isn't juggling a fourth job on top of claims/omission/fabrication) — for now, factor a learner-variable page out by hand when a claim reports a finding about a learner characteristic (e.g. "X predicts/moderates Y outcome"), rather than leaving it as a bare, unlinked claim.
2. Check if a page already exists (index.md or grep by name)
3. If new: create a page in the correct folder using the template below
4. If existing: merge new content into the right sections; append to ## Key Sources (or ## Evidence for claims); log the change
5. Cross-link: add markdown links like [Example Page](../principles/example-page.md) to related pages already in the wiki
6. Update index.md — run python3 scripts/build_indexes.py to regenerate it and every per-folder index from disk state
7. Append an entry to log.md under today's ## YYYY-MM-DD heading: * **Ingest**: [page](folder/page.md) — [source] (or run python3 scripts/log_revision.py <page> --by <actor> --type ingest --desc "...", which updates the page's generated field, its revision card, and log.md in one step)
2. Query¶
Answer a question by reading the wiki.
Steps:
1. Search index.md for relevant pages
2. Read those pages; follow the markdown links (slug.md / ../folder/slug.md) as needed
3. Synthesize across pages; cite page names and claim IDs
4. Flag gaps: if the answer requires a page that doesn't exist, note it
3. Lint¶
Health-check the wiki: python3 scripts/lint.py [--fix].
Checks:
- Broken cross-links (slug.md / ../folder/slug.md link target not found)
- Pages with status: draft and no description
- Claim pages missing an evidence strength rating
- Principles missing at least one claim link
- Claim evidence entries missing a DOI or URL
Evidence tags¶
Used inline in principle, pattern, element, and strategy pages when citing claims. The tag describes the direction of the claim's effect on the page's topic, not just evidence strength.
| Tag | Meaning |
|---|---|
| [+S] | Supports — strong (consistent experimental/meta-analytic) |
| [+M] | Supports — moderate |
| [+W] | Supports — weak / emerging |
| [~S], [~M], [~W] | Contextual / mixed — effect depends on conditions (e.g. expertise reversal: works for novices, not experts) |
| [-S], [-M], [-W] | Contradicts or reduces effectiveness — strength varies |
| [X] | Contradicted / discredited |
Rule: Claims cited in a Constraints section should use [-] (negative effect) or [~] (contextual/mixed), never [+]. A constraint describes a condition where the approach fails or causes harm — the tag should reflect that direction, even if the underlying claim is phrased positively (e.g., "practice improves transfer" cited as evidence that lack of practice hurts outcomes → [-S]).
Always link the tag to a claim page: [Claim statement](../claims/example-claim.md) [+M]
Relation symbols (cross-link annotations)¶
+supports / reinforces~contextual / mixed / depends on conditions–contradicts / undermines
Status values¶
| Status | Meaning |
|---|---|
draft |
Skeleton or stub; content not reviewed |
review |
Content present; needs expert review |
stable |
Reviewed and considered reliable |
deprecated |
Superseded or discredited; kept for history |
Frontmatter fields¶
Every content page (principle, element, pattern, design, product, strategy, process, method, research method, theory, learner-variable, claim) carries this OKF-conformant frontmatter:
| Field | Required | Meaning |
|---|---|---|
type |
Yes | principle | element | pattern | design | product | strategy | process | method | research-method | theory | learner-variable | claim |
title |
Recommended | Display name — normally matches the page's # H1 |
description |
Recommended | One-sentence summary, used in index listings |
status |
Recommended | See Status values above |
generated |
Recommended | { by: <actor>, at: <date> } — who/what last wrote the page and when, replacing the old last_edited/edited_by pair |
sources |
When applicable | List of { id, resource, title, author, q, i, n } entries parsed from ## Key Sources (or ## Evidence for claims) — a structured mirror of the citations already in the body, not a replacement for them |
verified |
Optional | List of { by: <actor>, at: <date> } confirmation events — see Trust tiers below. Absent on every page until someone explicitly reviews it; never set this yourself just because a page looks complete |
id |
On identified kinds | Equal to the filename slug, on elements/principles/patterns/claims/learner-variables — the stable name design documents resolve by (see Page identity below) |
aliases |
After a rename | Every former slug of this page, retained forever, so a document naming the old one still resolves |
evidence_strength, author, grain_size |
Type-specific | Extra scalar fields kept as-is per page type (see templates below); OKF tolerates extra frontmatter keys |
Actor convention for generated.by (and any other identity field): <tool>/unspecified for an agent/tool (e.g. claude/unspecified, codex/unspecified), human:<id> for a person, process:<id> for an unattended batch job (e.g. process:wiki-ingest). Never invent a specific model version you're not certain of — unspecified is fine.
Trust tiers (verified)¶
verified is a different axis from evidence_strength, and the two should never be conflated:
evidence_strength(and the per-studyq/icodes in a claim's## Evidencesection) describe how strong the underlying research is — is this a meta-analysis or a single case study.verifieddescribes whether a human has actually checked that this wiki page faithfully represents that research — a claim can haveevidence_strength: strongand still be completely unverified, because the LLM that wrote the page could have paraphrased a finding wrong, mistagged an effect's direction, or introduced a citation error that nothing has caught yet.
OKF derives three trust tiers from the verified field:
| Tier | Condition |
|---|---|
| unverified | No verified key present (the default for every freshly ingested or LLM-edited page) |
| machine-confirmed | verified present, but only by non-human: actors (e.g. a lint pass) |
| human-reviewed | verified present with at least one human:<id> actor |
Format (a list, so repeated reviews over time each add an entry — OKF also tolerates a bare single mapping):
verified:
- by: human:david
at: 2026-08-22
Add a verified entry when a human substantively reviews a page's content for accuracy — not on every procedural PR approval. The easiest way is:
python3 scripts/log_revision.py <page> --by human:<id> --type status --desc "Reviewed for accuracy" --verify
which appends the verified entry alongside its normal generated/log-update work. Never add a verified entry yourself (as an agent) just because a page looks complete or well-sourced — that defeats the point of the tier. python3 scripts/lint.py flags any status: stable page that has no verified entry, since "stable" should mean someone actually checked it, not just that it looks finished.
Page-type banner¶
Every content page carries a one-line banner directly under its # H1, naming
the page's type and linking back to its section index:
# Cooperative Learning
> **Principle** · [All principles](index.md)
This exists because 73 slugs live in more than one type folder — cooperative-learning
and direct-instruction each exist in all four of principles/elements/patterns/strategies,
with near-identical titles. Frontmatter carries type, but mkdocs strips frontmatter out
of the rendered page entirely, so on the docs site, in GitHub's file view, in the dashboard's
edit box, and in whatever an agent reads during an ingest, the folder in the URL was the only
thing distinguishing them. A blockquote renders as a visible callout in both GitHub and
mkdocs-material.
The banner's label follows the folder the page is in, which is what actually determines
its section — so where frontmatter type and the folder disagree, that's a real data bug
(the page is either misfiled or mislabelled) and a human decides which. lint.py's
check_type_banner verifies all three agree — banner present, label matches folder, and
frontmatter type matches folder — on every health run.
Run python3 scripts/add_type_banner.py --apply after any batch that creates pages; it's
idempotent (updates an existing banner in place rather than stacking a second one), so it's
safe to re-run at any time. --check reports without writing.
Yes, this duplicates type: from frontmatter into the body — the same tradeoff this schema
already accepts for sources: mirroring the citations in ## Key Sources: a structured field
and a human-readable rendering of the same fact, kept in sync by a lint check rather than by
dropping one.
Seeing the rest of the frontmatter¶
The banner covers one field. docs_hooks/page_metadata.py — a native mkdocs hook, so no
new dependency — appends a collapsed Page metadata panel to every content page with the
whole block: type, id, aliases, status, who generated it and when, the sources
count and ids, and anything else the page carries, including fields added to the schema
later.
It also renders the trust tier that CLAUDE.md derives from verified but nothing has
ever displayed — unverified / machine-confirmed / human-reviewed. That turned out to matter
immediately: no page in the wiki carries a verified: entry. The only match in the repo
is the example in this file. Every one of the 3,721 content pages reads "unverified — no one
has confirmed this page's content", which is accurate and was previously invisible.
Index pages, log.md and this file are skipped, and so is any page without a type: —
those are OKF-reserved or not content.
Renaming a page¶
Renaming a page is two jobs, and git mv only does the first. Nothing else in
the wiki updates the pages that link to the one you moved, so a rename lands as
a set of silently broken cross-links.
The dangerous version is a rename that is correct in its own tree and breaks
links only when merged forward, because the branch was cut before the linking
pages existed. That has already happened once here: merging the scraper branch
orphaned 17 links to a_finder's_guide_to_facts.md, renamed there to
a_finders_guide_to_facts.md.
After renaming, always run:
python3 scripts/update_links_for_renames.py --from-git --dry-run
python3 scripts/update_links_for_renames.py --from-git --apply
It reads the renames staged in git (so git mv first, or git add the rename),
and re-points every inbound link — same-folder, ../folder/, bundle-absolute,
and the percent-encoded spellings models emit for slugs containing ', ", ?,
,, (, ), &, +. Every rewrite is checked to resolve on disk before it is
kept, so the pass cannot turn a working link into a broken one. Pass --map
<file> instead of --from-git to drive it from an explicit old<TAB>new list.
Then confirm with python3 scripts/lint.py --type broken_links and regenerate
indexes with python3 scripts/build_indexes.py.
Cross-link conventions¶
- Cross-links are standard markdown links, relative to the linking page:
slug.mdfor another page in the same folder,../folder/slug.mdfor a page in a different folder (every content folder sits exactly one level under the wiki root, so../folder/always resolves correctly regardless of which folder you're linking from) - OKF also permits absolute bundle-relative paths (
/folder/slug.md) — this wiki uses the relative form instead because it works with plainmkdocs(the docs site's builder) with no extra plugin, whereas an absolute path renders as a literal domain-root URL once the site is hosted under a subpath - Slugs are lowercase, hyphen-separated:
worked-examples,cognitive-load-theory - Always include the folder in a cross-folder link so the target is unambiguous:
[Worked examples reduce novice search](../claims/worked-examples-reduce-novice-search.md) - Claims use semantic slugs:
../claims/worked-examples-reduce-novice-load.md, andid:in frontmatter equals that slug (see Page identity) - A link to a page that doesn't exist yet is tolerated (OKF requires consumers to tolerate broken links) — write the link anyway rather than leaving a bare TODO if you know the target slug, but don't invent slugs you haven't verified exist or are about to create
Folder map¶
ld-wiki/
CLAUDE.md ← this file (schema + operating guide)
index.md ← OKF bundle-root index; carries okf_version in frontmatter
reverse-index.json ← generated: which pages point AT each page (see The reverse index below)
wiki-index.json ← generated: id + kind + title per page, for the design-spec side (see The wiki index below)
evidence-scales.json ← what q3 / i2 / n= mean, as data (read by agents and by the docs hook)
evidence-dimensions.json ← the shared dimensions of evidence and design (learner state, goal, outcome, ...)
evidence.md ← generated: the state of the wiki's evidence (build_evidence_report.py)
log.md ← reserved OKF filename: append-only, date-grouped change log (current month)
log/ ← earlier months of log.md, one file each (okf_lib.rotate_log)
history/agent-log.md ← dated entries: what each wiki-wide change did and why (newest first)
principles/ ← design principles (what to do and why)
elements/ ← instructional components (building blocks)
patterns/ ← instructional patterns (reusable designs at lesson/unit level)
designs/ ← designs for one setting, course or population, using patterns
products/ ← named products and programmes others adopt, with their own frameworks and evidence
strategies/ ← teaching strategies (concrete activity recipes)
processes/ ← design processes: how a course gets made (ADDIE, SAM, design thinking, UbD)
methods/ ← design methods: the practices a process is made of (task analysis, personas, crosswalks)
research-methods/ ← methods for studying learning and evaluating education (education-specific only)
theories/ ← learning theories (explanatory frameworks)
learner-variables/ ← canonical learner characteristics (prior knowledge, self-efficacy, ...) claims link into
claims/ ← empirical claims with evidence
observations/ ← structured record of what each study actually did (see above)
research/ ← the research layer: where knowledge came from (see above)
protocols/<id>/<version>.yaml ← versioned, immutable governance configuration
studies/<id>/<version>.yaml ← versioned, immutable STUDY PLANS (see below)
releases/<id>/<version>.yaml ← versioned, immutable investigations
reviews/<review-id>.yaml ← structured contributions interrogating any of it
issues/<issue-id>.yaml ← one problem, however many people found it
sources/ ← bibliographic source pages (optional; most citations live inline in Key Sources / Evidence)
manifest.ndjson ← append-only log of every source reviewed, ingested or rejected (see Source Manifest below)
authorities.ndjson ← append-only log of citations a HUMAN verified against the real source (see Citation Authorities below)
scripts/
okf_lib.py ← shared OKF helpers (frontmatter parse/dump, link conversion, actor formatting)
ingest.py ← CSV → wiki pages batch ingest
enrich.py ← LLM-based stub enrichment (Claude/Gemini)
build_indexes.py ← regenerates index.md and every per-folder index from disk state
log_revision.py ← records a revision card + updates a page's `generated` field + appends to log.md
log_source_review.py ← appends one entry to sources/manifest.ndjson (see Source Manifest below)
add_type_banner.py ← inserts/refreshes the page-type banner under each page's H1 (see below)
add_evidence_summary.py ← the `> **Evidence** ·` header line on claim pages (see below)
update_links_for_renames.py ← after pages are renamed, re-points every inbound cross-link (see below)
page_identity.py ← stable `id:`/`aliases:` for the kinds design docs point at (see below)
build_reverse_index.py ← who points at this page, as shippable data (see below)
build_wiki_index.py ← the resolution table learning-design-spec reads (see below)
check_evidence_markers.py ← claim citations carrying no [±~][SMW] marker (see below)
evidence_rollup.py ← distinct-study roll-ups behind the evidence profiles and evidence.md
add_evidence_profile.py ← the `> **Evidence** ·` line on pages that cite claims
build_evidence_report.py ← evidence.md, the state of the wiki's evidence (generated)
search_index.py ← SQLite FTS5 index of the wiki in .cache/, used by search and linking
link_claims.py ← labels and writes Related Claims links between claims
merge_claims.py ← folds a duplicate claim into its canonical page, keeping an alias
link_pages.py ← links theories, principles, patterns, elements, strategies into the graph
check_load_bearing.py ← judges the most-cited claims' evidence against their sources; writes nothing
health_evidence.py ← the evidence and link section of the health report (load-bearing, single-study, i codes)
recode_impact.py ← recodes an i code to what the source prints, verified verbatim, else i?
smd_worklist.py ← WWC/ESSA review pages to extract next, for the SMD family (see above)
priority_worklist.py ← what to extract next: cited-but-unrecorded, hub, and evidence-less claims (see above)
mcp_server.py ← the wiki as MCP tools: search, fetch, resolve, backlinks, why (see above)
observation_lib.py ← the evidence layer's schema and validator (see above)
research_lib.py ← the research layer's schemas, validators and joins (see above)
check_research.py ← validate the research layer; --why traverses claim -> protocol
lint.py ← health-check (see Lint above)
verify_citation_edits.py ← after a citation tool writes: did it edit only citations? (see above)
check_embargo.py ← refuses to push embargoed research into this PUBLIC repo (see settled items)
mint_release_doi.py ← reserve, then publish, a Zenodo DOI for a research release (research/PUBLISHING.md)
build_site.sh ← the docs site: mkdocs (curated tier), build_longtail.py (the rest), Pagefind
build_longtail.py ← renders claims, strategies and non-canonical elements/theories as light pages
docs_hooks/ ← mkdocs hooks: page_metadata.py (metadata panel), two_tier.py (which tier a page is in)
Each folder's index.md is itself a reserved OKF filename: no frontmatter (except the bundle-root's okf_version), and a plain * [Title](slug.md) - description bullet listing grouped by status. Regenerate these with python3 scripts/build_indexes.py rather than hand-editing them.
Source Manifest¶
sources/manifest.ndjson is an append-only record of every source article the ingest pipeline has reviewed — whether it contributed pages or was rejected as out of scope. It exists so anyone (including people outside this project) can check whether a specific article has already been covered, or audit the whole scan, at a scale (eventually tens of thousands of articles) where a rendered list or one page per source stops being practical. It is not meant to be human-browsed — it's a data file, not a wiki page.
Format: one JSON object per line (NDJSON), never rewritten or reordered — only appended to.
{"id": "eric-ed265520", "title": "The Effects of High and Low Relevant Text Underlining on Test Performance.", "doi": null, "reviewed_at": "2026-08-27", "status": "ingested", "pages": ["elements/text-underlining-and-annotating.md", "theories/von-restorff-effect-text-marking.md"]}
{"id": "eric-ed616622", "title": "A Bibliography of Cognitive Information Processing Theory, Research, and Practice", "doi": null, "reviewed_at": "2026-08-27", "status": "rejected", "reason": "matched the search topic on keyword overlap only, but the source is a career/vocational-counseling bibliography, not a learning-science theory; out of scope for this wiki"}
Fields: id (source identifier — the ERIC/PMC/arXiv id from the automated pipeline, or doi:<doi> / a URL for manually-ingested articles), title, doi (nullable), reviewed_at (ISO date), status ("ingested" or "rejected"), and either pages (bundle-relative paths the source contributed to, for "ingested") or reason (why it didn't contribute, for "rejected").
"rejected" entries also carry reason_code, one of a closed set in okf_lib.REJECTION_CODES: opinion-piece, out-of-scope, no-ingestable-content and already-covered are verdicts about the source, and map one-to-one onto the exclusions E1–E4 in INCLUSION.md (approved by the maintainer 2026-09-24). Every research paradigm is eligible there, and domain or setting is never a reason to exclude; parse-error, validation-error and no-contributions-extracted are failed runs that say nothing about it. The code is what makes rejections countable, and it is what discovery reads: discover_articles.load_excluded_ids() now skips every source the manifest has settled, which it did not before. It used to skip only the ten benchmark articles and the ids in eval/corpus/processed_articles.json, and that registry is not committed. So a discovery run on any other machine re-surfaced, and paid to re-extract, sources already ingested or rejected. A failed run stays eligible, so the registry's bounded retry still decides it. The 27 rejections written before 2026-09-24 have no code and are not being backfilled, because the file is append-only. They are treated as verdicts, except the three exact strings ingest_extractions.py wrote for its machine failures, which are recognised by that wording. log_source_review.py requires --reason-code with --reason, and prints any earlier entry for the same id or DOI before writing. A second review is still written, because a re-review is legitimate, but it has to be a choice. lint.py fails on a code outside the set.
"ingested" entries also carry citations: {checked, crossref_reachable, removed, flagged} — what the citation gate found on the pages that source wrote. removed lists DOIs stripped because they resolved to the wrong paper; flagged lists findings left for a human (a DOI on two papers, invented journal metadata, an invented title). crossref_reachable: false means the network check could not run, so that line is an unverified ingest rather than a clean one — never read a bare "ingested" as "citations were checked".
Always append via the helper, never hand-edit the file:
python3 scripts/log_source_review.py --id "doi:10.1234/example" --title "Article Title" \
--status ingested --pages claims/foo.md elements/bar.md
python3 scripts/log_source_review.py --id "doi:10.1234/other" --title "Other Article" \
--status rejected --reason-code out-of-scope --reason "not learning-science, out of scope"
(scripts/ingest_extractions.py, the automated eval-pipeline ingest path, calls okf_lib.append_manifest_entry() directly instead of shelling out to this script — same effect.)
Looking something up (no need for a script — it's just NDJSON):
grep '"id": "eric-ed265520"' sources/manifest.ndjson
grep -i '"title":.*underlining' sources/manifest.ndjson
python3 -c "import json,sys; [print(l) for l in map(json.loads, open('sources/manifest.ndjson')) if l['status']=='rejected']"
Known gap: the CSV batch-import path (scripts/ingest.py, reading external ~/research_briefs/*.csv files) doesn't write to the manifest — those rows have no natural per-article identity to key an entry on.
Citation Authorities — the human's side of the record¶
sources/authorities.ndjson is where a person records what they checked against
the real source: the publisher's page, the book in hand, the article's own PDF. It is
append-only NDJSON like manifest.ndjson, keyed by the same author-year key
check_citations.py uses.
It exists because every automated check in this repo verifies against Crossref, and Crossref can only see what it indexes. 2,493 of the wiki's 12,893 citations — 1,232 distinct author-year keys — carry no DOI and no journal metadata at all. They are books, and all five checks are structurally blind to them: every one needs two variants of something to compare, and a book citation offers nothing to compare. Nothing has ever verified that Ambrose et al. (2010) is How Learning Works, Jossey-Bass, ISBN 978-0-470-61760-1. No lookup would say so. A person can.
python3 scripts/log_authority.py --key ambrose-2010 --by human:david \
--title "How Learning Works: Seven Research-Based Principles for Smart Teaching" \
--publisher "Jossey-Bass" --isbn 978-0-470-61760-1 --url https://www.wiley.com/... \
--note "Verified against the Wiley product page."
python3 scripts/apply_authorities.py --check # then --apply
python3 scripts/citation_worklist.py # what to look at next, ranked
Four things about it that are deliberate:
- An agent must never author one.
append_authority()refuses anyverified.bythat is nothuman:<id>, for the same reason CLAUDE.md forbids an agent adding a page'sverified:entry because the page looks complete. A machine-written "authority" is just another unverified assertion wearing a badge that says otherwise. "doi": nullis a verdict; an absentdoifield is not. The first says a human established that no DOI is registered, which makes any DOI a later batch invents for that key provably wrong and strippable without a lookup. The second says only "not established", and nothing may act on it. Same discipline ascrossref_reachable: falsevsflagged, and aserrorvswrong_paper.- It ratchets.
lint.py'scheck_authority_conflictsfails on any citation that contradicts a recorded verdict, so a settled decision survives the next enrichment batch. Repairing the pages alone does not: the model that invented a DOI for a book once will invent one again, and nothing in the corpus remembers otherwise. - Only the no-DOI verdict is auto-repaired.
apply_authorities.pyreports a wrong ISBN, a differing DOI or a mismatched title rather than rewriting it — the page may be citing a different edition, a chapter rather than the book, or a reprint, and only the person who checked can say which. The tooling has been confidently wrong about exactly this kind of "obvious" correction before (the Cook-Sather title case above).
scripts/citation_worklist.py is the reading end. It collapses all five checks onto the
author-year key — so several report lines about one source appear once — ranks by how
many citations ride on it, ranks the book backlog separately (different work: a person
and a publisher page, not a lookup), and drops keys already settled, so the list shrinks
as you work.
Page templates¶
Principle¶
---
type: principle
id: [principle-slug] # equal to the filename
title: [Principle Name]
description: [One-sentence summary of the recommendation]
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
---
# [Principle Name]
> **Principle** · [All principles](index.md)
## Description
[What this principle is and what it recommends.]
## Implications
### Context
#### Requirements
-
#### Constraints
-
### Target Learners
-
### Target Learning Objectives
-
### Theory
#### Supporting
-
#### Contradicting / Qualifying
-
### Claims
<!-- Link claims with evidence tags: [Claim statement](../claims/claim-slug.md) [+M] -->
-
## Related Principles
-
## Examples
<!-- Links to elements or patterns that apply this principle -->
-
## Key Sources
-
Element¶
---
type: element
id: [element-slug] # equal to the filename
title: [Element Name]
description: [One-sentence summary of what this element is]
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
---
# [Element Name]
> **Element** · [All elements](index.md)
## Description
[What this instructional element is; how it functions.]
## Design Implications
### Context
#### Requirements
-
#### Constraints
-
### Target Learners
<!-- Link to sub-claims: [Claim statement](../claims/claim-slug.md) -->
-
### Target Learning Goals
<!-- Link to sub-claims: [Claim statement](../claims/claim-slug.md) -->
-
### Affordances
<!-- Link to principles applied: [Principle Name](../principles/principle-slug.md) -->
-
### Claims
<!-- Claims bearing on this element, each a link to a claim page followed by its evidence tag, e.g. [+M] -->
-
## Related Elements
-
## Examples
<!-- Links to strategies that use this element, with ratings -->
-
## Key Sources
-
Pattern¶
---
type: pattern
id: [pattern-slug] # equal to the filename
title: [Pattern Name]
description: [One-sentence summary of what this pattern is]
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
author:
grain_size:
---
# [Pattern Name]
> **Pattern** · [All patterns](index.md)
## Description
[What this pattern is; how it works; what problem it solves.]
## Implications
### Context
#### Requirements
-
#### Constraints
-
#### Grain Size
[program / course / unit / lesson]
### Target Goals
<!-- Link to claims: [Claim statement](../claims/claim-slug.md) -->
-
### Target Learners
<!-- Link to claims: [Claim statement](../claims/claim-slug.md) -->
-
### Theory
#### Supporting
-
#### Contradicting / Qualifying
-
### Claims
<!-- Link claims with evidence tags -->
#### Supporting
-
#### Contradicting
-
## Design
### Sequence
<!-- Steps with links to elements: [Element Name](../elements/element-slug.md) -->
1.
### Affordances
<!-- Links to principles applied: [Principle Name](../principles/principle-slug.md) -->
-
### Personalization
<!-- How to adapt for non-target learners -->
-
## Related Patterns
-
## Examples
<!-- Links to products / lessons / courses with ratings -->
-
## Key Sources
-
Design¶
A design for one setting, course, population or product: a self-paced mobile Italian A1 course, a clinical onboarding programme, one study's lesson sequence. It is never general (that is a pattern); it says what it uses the general patterns for, given its learners, goals and constraints.
---
type: design
id: [design-slug] # equal to the filename
title: [Design Name]
description: [One sentence: for whom, in what setting, to what end]
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
author:
grain_size: # program / course / unit / lesson
---
# [Design Name]
> **Design** · [All designs](index.md)
## Description
[What the design is, who it is for and where it runs.]
## Context
### Learners
-
### Learning Goals
-
#### Requirements
-
#### Constraints
-
## Patterns Used
<!-- Each pattern the design applies, and what for: [Pattern Name](../patterns/pattern-slug.md): ... -->
-
## Sequence
<!-- Steps with links to elements and strategies -->
1.
### Claims
<!-- Claims bearing on this design's choices, with evidence tags -->
-
## Related Designs
-
## Key Sources
-
Product or Programme¶
A named product or programme others adopt or use. product_kind is one of software, curriculum,
assessment, dataset, programme, framework. Its own frameworks, tools and indicators are components
on this page, not pages of their own.
---
type: product
id: [product-slug] # equal to the filename
title: [Name, as its maker names it]
description: [One sentence: what it is and who makes or runs it]
product_kind: programme
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
---
# [Name]
> **Product or Programme** · [All products and programmes](index.md)
## Description
[What it is, who makes or runs it, who it is for, what it is meant to achieve.]
## Components
<!-- Its own frameworks, tools, indicators and instruments, as its sources describe them -->
-
### Claims
<!-- Claims about it or tested with it, with evidence tags -->
-
## Related Products and Programmes
-
## Key Sources
-
Research Method¶
A method for studying learning or evaluating education that is specific to education or especially useful there. A general social-science method is not a page.
---
type: research-method
id: [method-slug] # equal to the filename
title: [Method Name]
description: [One sentence: what question it answers and how]
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
---
# [Method Name]
> **Research Method** · [All research methods](index.md)
## Description
[What the method is, what question it answers, what data it needs, and its known limits.]
## Accounts
<!-- How each source describes or uses the method -->
-
### Claims
-
## Related Research Methods
-
## Key Sources
-
Strategy¶
---
type: strategy
id: [strategy-slug] # equal to the filename
title: [Strategy Name]
description: [One-sentence summary of what this strategy is]
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
---
# [Strategy Name]
> **Strategy** · [All strategies](index.md)
## Description
[What this strategy is and how it is carried out.]
## Design Implications
### Context
#### Requirements
-
#### Constraints
-
#### Implementation Variability
-
### Target Learners
<!-- Link to sub-claims: [Claim statement](../claims/claim-slug.md) -->
-
### Target Learning Goals
<!-- Link to sub-claims: [Claim statement](../claims/claim-slug.md) -->
-
### Instructions
<!-- Steps with links to elements: [Element Name](../elements/element-slug.md) -->
1.
## Related Strategies
-
## Examples
<!-- Links to products with ratings -->
-
## Key Sources
-
Theory¶
---
type: theory
title: [Theory Name]
description: [One-sentence summary of what this theory proposes]
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
---
# [Theory Name]
> **Theory** · [All theories](index.md)
## Description
[What this theory proposes; its core mechanism or claim.]
## Implications
### Context
-
### Target Learners
-
### Target Learning Objectives
-
## Claims
<!-- Claims that derive from or test this theory: [Claim statement](../claims/claim-slug.md) [+M] -->
-
## Related Theories
-
## Examples
<!-- Links to patterns and principles that apply this theory -->
-
## Key Sources
-
Learner Variable¶
A canonical page per distinct learner characteristic/variable (prior knowledge, self-efficacy, working memory capacity, spatial ability, ...). Claims that report a finding about the variable link into it, the same way claims link into theories — this keeps "prior knowledge," "prior domain knowledge," and "background knowledge" from three different articles as one page instead of three fragmented, undiscoverable mentions. Schema-ready but not yet part of the automated single-pass extraction prompt — see the Ingest section above for why, and factor these out by hand for now when a claim clearly reports a learner-characteristic finding.
---
type: learner-variable
id: [variable-slug] # equal to the filename
title: [Variable Name]
description: [One-sentence definition of this learner characteristic]
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
---
# [Variable Name]
> **Learner Variable** · [All learner variables](index.md)
## Description
[What this learner variable is; how it's typically measured or operationalized.]
## Implications
### Context
-
### Target Learners
-
### Target Learning Objectives
<!-- Learning outcomes this variable has been shown to affect -->
-
## Claims
<!-- Claims reporting findings about this variable, with evidence tags: [Claim statement](../claims/claim-slug.md) [+M] -->
-
## Related Learner Variables
-
## Examples
<!-- Links to principles/elements/patterns/strategies that account for this variable -->
-
## Key Sources
-
Claim¶
---
type: claim
title: [Claim statement — one sentence, present tense]
id: [claim-slug] # equal to the filename — what a design doc cites
status: draft
generated:
by: <actor>
at: YYYY-MM-DD
evidence_strength: # strong / moderate / weak / mixed
---
# [Claim statement — one sentence, present tense]
> **Claim** · [All claims](index.md)
[Optional 1–2 sentence clarification of scope or mechanism.]
## Subclaims
<!-- Each subclaim is a 1-sentence lit-review summary.
Prefix with quality and impact scores drawn from the supporting evidence.
Link to the evidence anchor using standard markdown: [→ Author Year](#author-year) -->
`q? i?` [One-sentence summary of the finding and scope.] [→ Author Year](#author-year)
## Evidence
<!-- One entry per study. Heading slug becomes the anchor target for subclaim links,
and (via scripts/migrate_to_okf.py's parser) the `id` of the matching entry in
the frontmatter `sources:` list.
Show full APA citation with DOI as a hyperlink.
Then: quality · impact · n codes with plain-language explanations.
Then: 2–4 sentence plain-language description. Link instructional elements to wiki pages.
Avoid unexplained abbreviations or jargon. -->
### Author Year
Author, A., & Author, B. (Year). Title. *Journal, vol*(issue), pages. [doi:...](https://doi.org/...)
`q? · [e.g. peer-reviewed RCT / quasi-experiment / meta-analysis]` · `i? · [e.g. large effect, d=0.9]` · `n=?` · `[kind] · r?`
[2–4 sentences: study design, participants (who, how many, what context), conditions or intervention, and findings in plain language. Link any instructional elements used to their wiki pages, for example `[worked examples](../elements/demonstration.md)` and `[practice tasks](../elements/practice.md)`.]
## Discussion
<!-- Prose section covering: contradictions, moderators, boundary conditions, open questions.
Link to related claim pages where relevant. -->
## Related Claims
-
Evidence kind and rigour. Every entry's codes line ends with a span naming what sort of
evidence the study is and how well it does what that kind can do, e.g. qualitative · r3. Kinds:
causal, quant-synthesis, review, associational, qualitative, design, theoretical.
Rigour 1–3 is judged against the kind's own criteria (eval/kind-rigor/RUBRIC.md), never another
kind's, so a careful interview study is r3 and a confounded trial r1; r? means the text
available could not show it, and an entry with no text of its study is always r?. Both are
mirrored into sources[] as kind: and rigour:. scripts/code_kind_rigour.py writes them,
from the article or abstract, never from the wiki's own paraphrase. q stays, and is now read as
the design tier: where a study sits on the causal-design ladder, which answers "how good is this as
evidence of a cause" and not "how well was this done".
Design tiers (q): | q | Criteria | |---|----------| | 4 | Pre-registered RCT or well-powered meta-analysis | | 3 | Peer-reviewed experiment (not pre-registered) or systematic review | | 2 | Quasi-experiment, observational with controls, or narrative review | | 1 | Case study, expert opinion, or theoretical argument |
The codes live in three places, from one source. evidence-scales.json at the bundle
root is the definition — as data, said once. docs_hooks/page_metadata.py loads it to
render the human-facing panel, and each claim's frontmatter sources[] entries carry the
per-study q/i/n mirrored out of the body by scripts/sync_evidence_codes.py. So an
agent reads a tier and its meaning without parsing prose, and the reader's tables cannot
drift from the agent's, because there is only one copy.
sources:
- id: haak-et-al-2011
resource: "https://doi.org/10.1126/science.1204820"
title: "Haak, D. C., … (2011). Increased structure and active learning…"
q: 3
i: 2
n: large (multiple course sections at a research university)
kind: causal
rigour: 2
Subclaim prefixes are deliberately not mirrored: a subclaim's q3 i2 is a reading of the
evidence entry it links to, and that entry already carries the codes — a second copy would
be a second place for them to disagree with no way to tell which was right. q? is
preserved rather than dropped, because "somebody looked and could not establish it" is a
different statement from an absent field.
These two tables are also rendered on every claim page, as a collapsed "Reading the
evidence codes" panel inserted directly under ## Subclaims — see
docs_hooks/page_metadata.py. An evidence entry spells its codes out beside them
(q3 · quasi-experimental study · i2 · large effect · n=large), but a subclaim prefix is
bare q3 i2, and across the claims corpus there are 465 quality codes and 443 impact codes
with nothing next to them saying what the numbers mean. The tables were always here and
this file is in the nav as Schema & Guide, so it was a proximity problem, not a missing
docs one. Not tooltips: the codes sit inside code spans, and neither abbr nor Material's
tooltips reach inside a <code> element.
A claim's header states its weight. scripts/add_evidence_summary.py puts a second
banner line directly under the type banner, so an agent resolving research:<claim-slug>
and a reader landing on the page both see what the claim rests on without scrolling:
> **Claim** · [All claims](index.md)
> **Evidence** · 1 study · `q3` peer-reviewed experiment · `i2` medium · n=48
It reads the same sources[] codes, so the header cannot disagree with the entries it
summarises. It never collapses them into a verdict — a claim resting on studies coded
q2 and q4 reports q2–q4, not "moderate". That is what evidence_strength: tried and
why it produced nineteen spellings; the range is derived, a verdict would be invented. And
a claim with no coded evidence says > **Evidence** · none recorded yet rather than
carrying no line: 313 of 422 claim pages are stubs, and "no line" cannot be told from "the
script never ran here".
The codes line is written two ways, and the parser must read both.
`q3 · peer-reviewed experiment · i2 · medium effect · n=48` one span
`q3 · peer-reviewed experiment` · `i2 · medium effect` · `n=48` three spans
The three-span form is dominant — 108 entries against 22 — and the first version of
parse_evidence_codes required a single span, so it captured q from both and i/n
from only the 22, silently. It had been written from one example page without checking
which spelling the corpus used. Fixed: it takes every backtick span on the codes line and
looks for each code independently, recovering i and n on 121 entries. Coverage went
from 143/22/22 to 149/149/143.
Impact magnitude (i): until 2026-09-24 i0 meant "negligible or unclear", and the extraction prompt wrote i0 whenever an article printed no effect size. Prompt v128 and the validator now write null for that case, rendered i?. An older i0 may still mean either, so read its evidence text; the 11 claim pages coding i: 0 have not been recoded.
| i | Rough effect size |
|---|-------------------|
| 3 | Large (d ≥ 0.8 or equivalent) |
| 2 | Medium (d 0.4–0.79) |
| 1 | Small (d 0.2–0.39) |
| 0 | Negligible: an effect size is reported and it is below d 0.2 |
| ? | No effect size reported: someone looked and the source does not say. Never read as 0 |
Ingest notes for agents¶
- When a CSV field lists multiple items separated by commas or semicolons, expand each into a list item
- When a field references another page by name (e.g., "Cognitive Load Theory"), convert it to a markdown link using the slugified name:
[Cognitive Load Theory](../theories/cognitive-load-theory.md) - When research support / impact fields contain citations, extract them into
## Key Sources(and, once parsed, the frontmattersources:list) and create or linksources/pages - Mark pages
status: reviewon ingest (no page stays draft since 2026-10-15); only a person'sverified:entry makes a pagestable - Never delete content on update — move superseded content to a
<!-- deprecated -->comment block - Never write a raw, un-slugified name straight into a file path — a name containing
/(e.g. "Stand Up / Sit Down") will be interpreted as a subdirectory separator. Always pass names throughslugify()before joining them into a path.