<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Oscar Rieken</title>
    <description>The latest articles on DEV Community by Oscar Rieken (@orieken).</description>
    <link>https://dev.to/orieken</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F409515%2F4b302a29-5754-4f45-a9ad-a8b955d04751.jpeg</url>
      <title>DEV Community: Oscar Rieken</title>
      <link>https://dev.to/orieken</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/orieken"/>
    <language>en</language>
    <item>
      <title>Toward an AI Operating System: Context Engineering as the First Runtime Primitive</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Wed, 08 Jul 2026 01:55:33 +0000</pubDate>
      <link>https://dev.to/orieken/toward-an-ai-operating-system-context-engineering-as-the-first-runtime-primitive-58go</link>
      <guid>https://dev.to/orieken/toward-an-ai-operating-system-context-engineering-as-the-first-runtime-primitive-58go</guid>
      <description>&lt;p&gt;After publishing the first few posts about &lt;code&gt;ai-assistant-dot-files&lt;/code&gt;, the obvious next question is:&lt;/p&gt;

&lt;p&gt;Where does this go?&lt;/p&gt;

&lt;p&gt;The tempting answer is "more agents."&lt;/p&gt;

&lt;p&gt;I do not think that is the right answer.&lt;/p&gt;

&lt;p&gt;The more interesting direction is an AI Operating System: not an OS in the kernel-and-device-driver sense, but a runtime model for governed agentic work.&lt;/p&gt;

&lt;p&gt;That distinction matters. The goal is not to wrap every task in bigger orchestration theater. The goal is to ask what runtime primitives agentic systems actually need if they are going to be useful, inspectable, and safe enough to trust with real software work.&lt;/p&gt;

&lt;p&gt;The first primitive is context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why context comes first
&lt;/h2&gt;

&lt;p&gt;In the current framework, Context Engineering is already doing operating-system-shaped work.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;context-engineer&lt;/code&gt; agent builds a &lt;code&gt;context-manifest.md&lt;/code&gt; before the delivery pipeline starts. That manifest scopes relevant files, prior deliveries, Knowledge Items, ADRs, and token budget pressure.&lt;/p&gt;

&lt;p&gt;That is not just prompt hygiene.&lt;/p&gt;

&lt;p&gt;It is resource management.&lt;/p&gt;

&lt;p&gt;An operating system decides what a process can see, which resources it can access, and how much room it has to work before it starts corrupting other work. Context Engineering plays a similar role for agents.&lt;/p&gt;

&lt;p&gt;It answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What should this agent see?&lt;/li&gt;
&lt;li&gt;What should it not see?&lt;/li&gt;
&lt;li&gt;Which prior knowledge is relevant?&lt;/li&gt;
&lt;li&gt;Which stale artifact should be summarized instead of loaded in full?&lt;/li&gt;
&lt;li&gt;How much of the context budget should this phase consume?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why I keep coming back to the phrase: treat the context window like a budget, not a junk drawer.&lt;/p&gt;

&lt;p&gt;If an AOS exists, context is one of its schedulable resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  The current system is not AOS yet
&lt;/h2&gt;

&lt;p&gt;The repo today is a Context Engineering Framework.&lt;/p&gt;

&lt;p&gt;It has one canonical &lt;code&gt;shared/&lt;/code&gt; layer, 24 agents, 53 skills, inter-agent contracts, a memory lifecycle, and platform projections for Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini/Antigravity, and Codex.&lt;/p&gt;

&lt;p&gt;That is real.&lt;/p&gt;

&lt;p&gt;The AOS idea is earlier.&lt;/p&gt;

&lt;p&gt;The notes in &lt;code&gt;docs/aos/AOS_Governance_Design_Pack.zip&lt;/code&gt; describe design seeds: capability, governance, learning, memory engineering, context engineering, and continuous improvement. They sketch pairs like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Context Engineer ↔ Context Auditor&lt;/li&gt;
&lt;li&gt;Memory Engineer ↔ Memory Auditor&lt;/li&gt;
&lt;li&gt;Prompt Architect ↔ Prompt Evaluator&lt;/li&gt;
&lt;li&gt;Orchestrator ↔ Scheduler&lt;/li&gt;
&lt;li&gt;Learning Engine ↔ Forgetting Engine&lt;/li&gt;
&lt;li&gt;Cost Optimizer ↔ Quality Optimizer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not a shipped runtime.&lt;/p&gt;

&lt;p&gt;It is a question set.&lt;/p&gt;

&lt;p&gt;And honestly, that is the part I trust most. A premature AOS would be easy to overbuild. A useful AOS has to start by finding which governance gaps are real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Governance is the operating model
&lt;/h2&gt;

&lt;p&gt;The strongest thing in the current framework is not the number of agents.&lt;/p&gt;

&lt;p&gt;It is that each important handoff has some kind of counterbalance.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docs/AGENT_REFERENCE.md&lt;/code&gt; names four kinds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;structural contracts&lt;/li&gt;
&lt;li&gt;downstream agent review&lt;/li&gt;
&lt;li&gt;human approval gates&lt;/li&gt;
&lt;li&gt;aggregate or delayed metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That model is small, but it changes the conversation.&lt;/p&gt;

&lt;p&gt;Instead of asking "Can the agent do the task?" we ask "What checks the agent's work?"&lt;/p&gt;

&lt;p&gt;That is the AOS-flavored question.&lt;/p&gt;

&lt;p&gt;An operating system is not just a place where programs run. It is a place where programs run under rules:&lt;br&gt;
permissions, scheduling, memory boundaries, process isolation, accounting, cleanup.&lt;/p&gt;

&lt;p&gt;For agents, the equivalent rules are things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does this agent have the right context and only the right context?&lt;/li&gt;
&lt;li&gt;Is its output structurally valid?&lt;/li&gt;
&lt;li&gt;Is there an independent reviewer where judgment matters?&lt;/li&gt;
&lt;li&gt;Are irreversible actions gated by human approval?&lt;/li&gt;
&lt;li&gt;Are repeated failures converted into learning rather than buried in chat history?&lt;/li&gt;
&lt;li&gt;Is stale or duplicated knowledge expired instead of retrieved forever?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those questions are less flashy than "autonomous swarm."&lt;/p&gt;

&lt;p&gt;Good. Flash is usually where the bugs breed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory is the second runtime primitive
&lt;/h2&gt;

&lt;p&gt;If context is what an agent sees now, memory is what survives the run.&lt;/p&gt;

&lt;p&gt;The current memory model uses a promotion lifecycle:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Capture -&amp;gt; Candidate -&amp;gt; Audit -&amp;gt; Approve -&amp;gt; Index -&amp;gt; Retrieve -&amp;gt; Expire&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That lifecycle is important because it refuses to treat memory as a pile of saved notes.&lt;/p&gt;

&lt;p&gt;In an AOS model, memory needs governance the same way context does.&lt;/p&gt;

&lt;p&gt;Not every observation deserves to become durable. Not every durable item deserves to live forever. Not every retrieved item deserves to enter the active context window.&lt;/p&gt;

&lt;p&gt;The AOS notes call out the pair:&lt;/p&gt;

&lt;p&gt;Memory Engineer ↔ Memory Auditor&lt;/p&gt;

&lt;p&gt;That feels right. One side promotes and organizes. The other side asks whether the memory is reusable, non-duplicative, supported by evidence, and still true.&lt;/p&gt;

&lt;p&gt;Memory without forgetting is just entropy with a search box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entropy management might be the underrated subsystem
&lt;/h2&gt;

&lt;p&gt;One of the AOS notes sketches an &lt;code&gt;Entropy Manager&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;remove duplicate knowledge&lt;/li&gt;
&lt;li&gt;detect stale docs&lt;/li&gt;
&lt;li&gt;detect unused agents&lt;/li&gt;
&lt;li&gt;merge overlapping rules&lt;/li&gt;
&lt;li&gt;reduce repository entropy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is not glamorous.&lt;/p&gt;

&lt;p&gt;It might be essential.&lt;/p&gt;

&lt;p&gt;Any agent framework that learns will also accumulate. It will accumulate rules, prompts, skills, memories, exceptions, platform quirks, and "temporary" workarounds that quietly become permanent.&lt;/p&gt;

&lt;p&gt;Without an entropy-management function, learning turns into clutter.&lt;/p&gt;

&lt;p&gt;The forgotten Cursor symlink story is a small example. The repo already had &lt;code&gt;.cursor/agents&lt;/code&gt; and &lt;code&gt;.cursor/skills&lt;/code&gt; symlinked to &lt;code&gt;shared/&lt;/code&gt;, but the decision was not documented, checked, or integrated into the platform model. The fix was not only to make it work. The fix was to make it maintained.&lt;/p&gt;

&lt;p&gt;That is AOS territory too.&lt;/p&gt;

&lt;p&gt;Not "can the system do the thing once?"&lt;/p&gt;

&lt;p&gt;"Can the system preserve the reason it does the thing?"&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do not want AOS to become
&lt;/h2&gt;

&lt;p&gt;There are a few traps I want to avoid.&lt;/p&gt;

&lt;p&gt;First: AOS should not become a cooler name for a giant prompt library.&lt;/p&gt;

&lt;p&gt;Second: it should not pretend every tool has the same capabilities. The current framework already learned that lesson through its platform tier system. Claude Code, Cursor, Copilot, Gemini, Windsurf, and Codex do not expose the same runtime primitives.&lt;/p&gt;

&lt;p&gt;Third: it should not optimize for maximum autonomy by default.&lt;/p&gt;

&lt;p&gt;Autonomy without counterbalances is not maturity. It is just speed with a longer blast radius.&lt;/p&gt;

&lt;p&gt;The goal is governed agency: more work can happen through agents because the system knows where to place boundaries, reviews, summaries, approvals, and forgetting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The direction
&lt;/h2&gt;

&lt;p&gt;If the current framework answers:&lt;/p&gt;

&lt;p&gt;"How do we define agents, skills, rules, context, memory, and governance once, then project them into many AI coding tools?"&lt;/p&gt;

&lt;p&gt;Then AOS asks:&lt;/p&gt;

&lt;p&gt;"What runtime model lets those agents operate with explicit context, memory, scheduling, permissions, fitness functions, and entropy control?"&lt;/p&gt;

&lt;p&gt;That is the north star.&lt;/p&gt;

&lt;p&gt;But the path there should stay grounded:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Improve Context Engineering first.&lt;/li&gt;
&lt;li&gt;Add Context Auditing before adding more autonomy.&lt;/li&gt;
&lt;li&gt;Keep Memory Engineering evidence-based and approval-driven.&lt;/li&gt;
&lt;li&gt;Track fitness functions like context precision, retrieval quality, token efficiency, memory quality, and entropy.&lt;/li&gt;
&lt;li&gt;Treat every new subsystem as guilty until it proves it closes a real gap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The point is not to build an AI Operating System because the metaphor sounds good.&lt;/p&gt;

&lt;p&gt;The point is to discover which parts of software delivery become safer and more comprehensible when agents&lt;br&gt;
run inside a governed runtime instead of a chat transcript.&lt;/p&gt;

&lt;p&gt;That is the direction I want to explore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source trail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/README.md" rel="noopener noreferrer"&gt;README.md&lt;/a&gt; — current framework shape: canonical &lt;code&gt;shared/&lt;/code&gt; layer, 24 agents, 53 skills, six platform targets.&lt;/li&gt;
&lt;li&gt;`docs/runbooks/context-engineering.md](&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/runbooks/context-engineering.md" rel="noopener noreferrer"&gt;https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/runbooks/context-engineering.md&lt;/a&gt;) — Context/Memory/Learning distinction and context manifest role.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/runbooks/memory-engineering.md" rel="noopener noreferrer"&gt;docs/runbooks/memory-engineering.md&lt;/a&gt; — memory promotion lifecycle.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/AGENT_REFERENCE.md" rel="noopener noreferrer"&gt;docs/AGENT_REFERENCE.md&lt;/a&gt; — counterbalance model for every current agent.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/aos/AOS_Governance_Design_Pack.zip" rel="noopener noreferrer"&gt;docs/aos/AOS_Governance_Design_Pack.zip&lt;/a&gt; — exploratory AOS notes, especially &lt;code&gt;00-AOS-Vision.md&lt;/code&gt;, &lt;code&gt;01-Governance-Checks-and-Balances.md&lt;/code&gt;, &lt;code&gt;02-Context-Governance.md&lt;/code&gt;, &lt;code&gt;08-Fitness-Functions.md&lt;/code&gt;, and &lt;code&gt;09-Entropy-Manager.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/features/context-engineering-framework/TODO.md" rel="noopener noreferrer"&gt;docs/features/context-engineering-framework/TODO.md&lt;/a&gt; — Epic 30 forgotten symlink story and the concrete drift-prevention fix.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>The Forgotten Symlink: Why 'It Works' Is Not the Same as 'It Is Maintained'</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Wed, 08 Jul 2026 01:13:08 +0000</pubDate>
      <link>https://dev.to/orieken/the-forgotten-symlink-why-it-works-is-not-the-same-as-it-is-maintained-17hh</link>
      <guid>https://dev.to/orieken/the-forgotten-symlink-why-it-works-is-not-the-same-as-it-is-maintained-17hh</guid>
      <description>&lt;p&gt;The best bug in a tooling repo is the one where you discover someone already solved your problem.&lt;/p&gt;

&lt;p&gt;The worst version is realizing nobody remembered.&lt;/p&gt;

&lt;p&gt;While adding native Cursor agent and skill support to &lt;code&gt;ai-assistant-dot-files&lt;/code&gt;, we found that &lt;code&gt;.cursor/agents&lt;/code&gt; and &lt;code&gt;.cursor/skills&lt;/code&gt; already existed in the repo as symlinks.&lt;/p&gt;

&lt;p&gt;They pointed to &lt;code&gt;../shared/agents&lt;/code&gt; and &lt;code&gt;../shared/skills&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That was exactly the design we needed.&lt;/p&gt;

&lt;p&gt;They had been committed on 2026-04-09 in commit &lt;code&gt;d0b54d3&lt;/code&gt;, with the message "expanded to work for all platforms."&lt;/p&gt;

&lt;p&gt;Then they faded out of the system's working memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The context
&lt;/h2&gt;

&lt;p&gt;The framework has one canonical source of truth: &lt;code&gt;shared/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Agents, skills, rules, contracts, Knowledge Items, the domain dictionary, team topology, and platform&lt;br&gt;
registry all live there. Platform-specific files are either symlinked or generated from that canonical&lt;br&gt;
layer.&lt;/p&gt;

&lt;p&gt;That is how one repo can project the same rules into Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini/Antigravity, and OpenAI Codex.&lt;/p&gt;

&lt;p&gt;The repo has a capability tier system because those tools do not all support the same primitives.&lt;/p&gt;

&lt;p&gt;Claude Code has full native agent orchestration. Cursor used to be treated mainly as a rules/persona target,&lt;br&gt;
with generated &lt;code&gt;.mdc&lt;/code&gt; files. Then Cursor shipped native Agent Skills and subagents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.cursor/skills/*/SKILL.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.cursor/agents/*.md&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That changed the design.&lt;/p&gt;

&lt;p&gt;The right move was not to generate Cursor-specific copies of the agents and skills. The right move was to symlink Cursor directly to &lt;code&gt;shared/&lt;/code&gt;, just like Claude Code already did.&lt;/p&gt;

&lt;p&gt;And then we found out the repo already had those symlinks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The missing part was not the symlink
&lt;/h2&gt;

&lt;p&gt;The symlinks existed.&lt;/p&gt;

&lt;p&gt;What did not exist was a system that made them visible, verified, and meaningful.&lt;/p&gt;

&lt;p&gt;They were created before the capability tier system explained why they mattered. They were not part of the parity check. They were not clearly represented in the platform registry. They were not woven into the install flow as a first-class design choice.&lt;/p&gt;

&lt;p&gt;So the fix was not "create symlinks."&lt;/p&gt;

&lt;p&gt;The fix was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;document Cursor's mixed strategy in &lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;update &lt;code&gt;shared/platform-registry.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;teach &lt;code&gt;install.sh&lt;/code&gt; to symlink &lt;code&gt;.cursor/agents&lt;/code&gt; and &lt;code&gt;.cursor/skills&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;update &lt;code&gt;scripts/check-parity.sh&lt;/code&gt; so those symlinks cannot silently disappear&lt;/li&gt;
&lt;li&gt;update the README capability matrix with the real Cursor behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part matters most.&lt;/p&gt;

&lt;p&gt;If a behavior is important but not checked, it is folklore.&lt;/p&gt;

&lt;p&gt;Folklore decays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift is not only duplication
&lt;/h2&gt;

&lt;p&gt;When people talk about configuration drift, they usually mean duplicated text getting out of sync.&lt;/p&gt;

&lt;p&gt;That is one kind.&lt;/p&gt;

&lt;p&gt;This was another: a correct structural decision existed, but the rest of the system did not know how to protect it.&lt;/p&gt;

&lt;p&gt;The repo already had a parity script because earlier versions had copied instructions into &lt;code&gt;.cursorrules&lt;/code&gt;, &lt;code&gt;copilot-instructions.md&lt;/code&gt;, and &lt;code&gt;CLAUDE.md&lt;/code&gt; independently. That drift was easy to see once you knew to look for it.&lt;/p&gt;

&lt;p&gt;The forgotten symlink was quieter.&lt;/p&gt;

&lt;p&gt;It did not fail loudly. It just stopped influencing future design.&lt;/p&gt;

&lt;h2&gt;
  
  
  "It works" is a weak invariant
&lt;/h2&gt;

&lt;p&gt;There is a tempting maintenance posture that says: if the file exists and the app loads it, we are done.&lt;/p&gt;

&lt;p&gt;This story made the opposite case.&lt;/p&gt;

&lt;p&gt;For cross-tool AI configuration, "it works" is not enough. The stronger invariant is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the source of truth is named&lt;/li&gt;
&lt;li&gt;each projection mechanism is documented&lt;/li&gt;
&lt;li&gt;platform capability differences are explicit&lt;/li&gt;
&lt;li&gt;parity checks fail when the projection drifts&lt;/li&gt;
&lt;li&gt;install behavior recreates the intended shape&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That turns a one-off fix into a maintained feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The useful lesson
&lt;/h2&gt;

&lt;p&gt;The lesson is not "use symlinks."&lt;/p&gt;

&lt;p&gt;Sometimes copying is right. Sometimes generation is right. Sometimes a platform cannot follow references, so inlining is the only honest option. Cursor rules still need generated &lt;code&gt;.mdc&lt;/code&gt; files with inlined content, even though Cursor agents and skills can symlink directly to &lt;code&gt;shared/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The lesson is: make the maintenance contract match the platform's actual capabilities.&lt;/p&gt;

&lt;p&gt;For Cursor, the result is now mixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;agents and skills: symlink to &lt;code&gt;shared/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;rules: generate fully inlined &lt;code&gt;.mdc&lt;/code&gt; files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That mixed strategy is less elegant than pretending everything works the same way.&lt;/p&gt;

&lt;p&gt;It is also truer.&lt;/p&gt;

&lt;p&gt;And in tooling, true ages better than elegant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source trail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/features/context-engineering-framework/TODO.md" rel="noopener noreferrer"&gt;docs/features/context-engineering-framework/TODO.md&lt;/a&gt; — Epic 30 Cursor native skills/agents parity and the 2026-04-09 symlink rediscovery.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/ARCHITECTURE.md" rel="noopener noreferrer"&gt;docs/ARCHITECTURE.md&lt;/a&gt; — Cursor mixed strategy and capability tier update.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/shared/platform-registry.json" rel="noopener noreferrer"&gt;shared/platform-registry.json&lt;/a&gt; — Cursor platform notes and install strategy.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/scripts/check-parity.sh" rel="noopener noreferrer"&gt;scripts/check-parity.sh&lt;/a&gt; — explicit &lt;code&gt;.cursor/agents&lt;/code&gt; and &lt;code&gt;.cursor/skills&lt;/code&gt; symlink checks.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/README.md" rel="noopener noreferrer"&gt;README.md&lt;/a&gt; — updated platform capability matrix.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Memory Engineering Is a Promotion Pipeline, Not a Pile of Notes</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Wed, 08 Jul 2026 00:08:10 +0000</pubDate>
      <link>https://dev.to/orieken/memory-engineering-is-a-promotion-pipeline-not-a-pile-of-notes-3eee</link>
      <guid>https://dev.to/orieken/memory-engineering-is-a-promotion-pipeline-not-a-pile-of-notes-3eee</guid>
      <description>&lt;p&gt;A lot of AI memory systems start with the same temptation:&lt;/p&gt;

&lt;p&gt;"Just save the useful thing."&lt;/p&gt;

&lt;p&gt;That sounds harmless until the knowledge base becomes a junk drawer. Half the notes are too specific, a few are duplicates, some are obsolete, and nobody knows which ones the agent should trust.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;ai-assistant-dot-files&lt;/code&gt;, the memory system is deliberately slower.&lt;/p&gt;

&lt;p&gt;It uses a promotion lifecycle:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Capture -&amp;gt; Candidate -&amp;gt; Audit -&amp;gt; Approve -&amp;gt; Index -&amp;gt; Retrieve -&amp;gt; Expire&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That lifecycle is documented in &lt;code&gt;docs/runbooks/memory-engineering.md&lt;/code&gt;, and the important word is not "capture."&lt;/p&gt;

&lt;p&gt;It is "candidate."&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing writes directly to memory
&lt;/h2&gt;

&lt;p&gt;The framework has a durable memory layer: Knowledge Items in &lt;code&gt;shared/knowledge/&lt;/code&gt;, ADRs in &lt;code&gt;docs/adrs/&lt;/code&gt;, the domain dictionary, team topology, a feature archive, and a registry at &lt;code&gt;shared/memory-registry.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But a lesson from a delivery does not jump straight into &lt;code&gt;shared/knowledge/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It first becomes a Candidate Record.&lt;/p&gt;

&lt;p&gt;That record has required fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Source&lt;/li&gt;
&lt;li&gt;Type&lt;/li&gt;
&lt;li&gt;Evidence&lt;/li&gt;
&lt;li&gt;Tags&lt;/li&gt;
&lt;li&gt;Expiration condition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then &lt;code&gt;memory-engineer&lt;/code&gt; audits it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is it reusable?&lt;/li&gt;
&lt;li&gt;Is it already covered?&lt;/li&gt;
&lt;li&gt;Is it too speculative?&lt;/li&gt;
&lt;li&gt;Does it belong as a Knowledge Item, or should it become a rule change, prompt edit, or ADR instead?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only after that does a human approve the destination.&lt;/p&gt;

&lt;p&gt;The design is intentionally similar to code review. Durable memory changes future behavior, so they deserve a paper trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rejection is a feature
&lt;/h2&gt;

&lt;p&gt;One of my favorite parts of the memory runbook is that it has explicit rejection rules.&lt;/p&gt;

&lt;p&gt;Do not promote a memory when it is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a one-off&lt;/li&gt;
&lt;li&gt;already covered&lt;/li&gt;
&lt;li&gt;too speculative&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That makes "zero candidates promoted this cycle" a healthy result, not a failure.&lt;/p&gt;

&lt;p&gt;This is where memory engineering starts to look less like note-taking and more like gardening. The point is not to preserve every leaf. The point is to keep the soil useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expiration matters
&lt;/h2&gt;

&lt;p&gt;The lifecycle also includes expiration.&lt;/p&gt;

&lt;p&gt;A Knowledge Item can become stale when the underlying code, agent, or pattern changes. It can be superseded by a better KI. Or usage analytics can show that it never appears in context manifests, which may mean it is not useful or just badly tagged.&lt;/p&gt;

&lt;p&gt;The repo does not delete those blindly. Expired KIs move to &lt;code&gt;shared/knowledge/expired/&lt;/code&gt; with a note.&lt;/p&gt;

&lt;p&gt;That choice matters because a wrong memory is still evidence. It tells you what the team used to believe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not build the big retrieval system now?
&lt;/h2&gt;

&lt;p&gt;There is a runbook for LightRAG integration at &lt;code&gt;docs/runbooks/lightrag-integration.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is intentionally no implementation.&lt;/p&gt;

&lt;p&gt;That is not an omission. It is a YAGNI decision.&lt;/p&gt;

&lt;p&gt;The current retrieval path is smaller: &lt;code&gt;search-ki&lt;/code&gt; searches Knowledge Items and ADRs; &lt;code&gt;query-memory&lt;/code&gt; works across the broader memory registry. The repo currently has 4 portable Knowledge Items, so building a bigger retrieval subsystem before the corpus needs it would add moving parts without solving an observed bottleneck.&lt;/p&gt;

&lt;p&gt;The runbook exists so the future integration has a shape if the need becomes real.&lt;/p&gt;

&lt;p&gt;That is the kind of "not yet" I trust: documented, intentional, and reversible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Governance by design, not vibes
&lt;/h2&gt;

&lt;p&gt;The memory system fits into a larger governance model.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docs/AGENT_REFERENCE.md&lt;/code&gt; lists every one of the 24 agents and names what checks its work: a structural&lt;br&gt;
contract, a downstream reviewer, a human approval gate, or an aggregate metric.&lt;/p&gt;

&lt;p&gt;Some gaps are real and stated plainly. For example, &lt;code&gt;test-driven-developer&lt;/code&gt; deliberately bypasses the full review chain for speed. The doc does not pretend otherwise.&lt;/p&gt;

&lt;p&gt;That same honesty shows up in memory.&lt;/p&gt;

&lt;p&gt;The system does not claim every remembered thing is true forever.&lt;/p&gt;

&lt;p&gt;It asks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Where did this come from?&lt;/li&gt;
&lt;li&gt;What evidence supports it?&lt;/li&gt;
&lt;li&gt;Who approved it?&lt;/li&gt;
&lt;li&gt;What would make it expire?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are small questions, but they change the shape of the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would copy into another project
&lt;/h2&gt;

&lt;p&gt;If you are adding memory to an AI workflow, I would start here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Do not let agents write directly to durable memory.&lt;/li&gt;
&lt;li&gt;Make every memory a candidate first.&lt;/li&gt;
&lt;li&gt;Require evidence and an expiration condition.&lt;/li&gt;
&lt;li&gt;Treat rejection as a valid outcome.&lt;/li&gt;
&lt;li&gt;Periodically compress duplicates instead of making retrieval disambiguate them forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hard part of memory is not remembering.&lt;/p&gt;

&lt;p&gt;It is staying worth remembering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source trail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/runbooks/memory-engineering.md" rel="noopener noreferrer"&gt;docs/runbooks/memory-engineering.md&lt;/a&gt; — full memory lifecycle and Candidate/Audit fields.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/runbooks/context-engineering.md" rel="noopener noreferrer"&gt;docs/runbooks/context-engineering.md&lt;/a&gt; — distinction between Context, Memory, and Learning.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/shared/memory-registry.json" rel="noopener noreferrer"&gt;shared/memory-registry.json&lt;/a&gt; — registry of durable memory sources and retrieval backends.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/AGENT_REFERENCE.md" rel="noopener noreferrer"&gt;docs/AGENT_REFERENCE.md&lt;/a&gt; — agent counterbalances and explicit gaps.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/features/context-engineering-framework/TODO.md" rel="noopener noreferrer"&gt;docs/features/context-engineering-framework/TODO.md&lt;/a&gt; — Epic 22 memory engineering and Epic 26 documentation-manager boundary cleanup.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/orieken/ai-assistant-dot-files/tree/main/docs/runbooks/lightrag-integration.md" rel="noopener noreferrer"&gt;docs/runbooks/lightrag-integration.md&lt;/a&gt; — documented future path, intentionally not implemented yet.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Treat the Context Window Like a Budget, Not a Junk Drawer</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Tue, 07 Jul 2026 23:54:12 +0000</pubDate>
      <link>https://dev.to/orieken/treat-the-context-window-like-a-budget-not-a-junk-drawer-5602</link>
      <guid>https://dev.to/orieken/treat-the-context-window-like-a-budget-not-a-junk-drawer-5602</guid>
      <description>&lt;p&gt;Most AI coding workflows treat context as something that happens accidentally.&lt;/p&gt;

&lt;p&gt;You open a few files. Paste a stack trace. Ask the model to inspect a directory. Then another. Then the chat grows heavy, the model starts missing earlier instructions, and everyone pretends the problem is "the model got weird."&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;ai-assistant-dot-files&lt;/code&gt;(I probably need to rename this now as its grown into something else)&lt;br&gt;
I wanted to treat the context window as a budget.&lt;/p&gt;

&lt;p&gt;Not a vibe. Not a giant bucket. A budget.&lt;/p&gt;

&lt;p&gt;The repo now ships a Context Engineering Framework that defines one canonical set of agents, skills, and rules in &lt;code&gt;shared/&lt;/code&gt;, then projects them into six AI coding tools: Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini/Antigravity, and OpenAI Codex. The current repo has 24 agents, 53 skills, 13 inter-agent&lt;br&gt;
contracts, and 6 platform targets.&lt;/p&gt;

&lt;p&gt;The core idea is simple: before an agent does serious work, another agent should decide what belongs in the room.&lt;/p&gt;

&lt;h2&gt;
  
  
  The context-engineer agent
&lt;/h2&gt;

&lt;p&gt;The framework has a dedicated &lt;code&gt;context-engineer&lt;/code&gt; agent. Its job is not to implement anything.&lt;/p&gt;

&lt;p&gt;Its job is to produce a &lt;code&gt;context-manifest.md&lt;/code&gt; before the rest of the pipeline starts.&lt;/p&gt;

&lt;p&gt;That manifest scopes the bounded context, identifies relevant files, surfaces Knowledge Items and ADRs, notes prior related deliveries, and estimates token budget pressure for the downstream agents.&lt;/p&gt;

&lt;p&gt;This matters because the feature-delivery pipeline is not one prompt. It is a sequence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;spec-writing&lt;/li&gt;
&lt;li&gt;product review&lt;/li&gt;
&lt;li&gt;context engineering&lt;/li&gt;
&lt;li&gt;analysis&lt;/li&gt;
&lt;li&gt;architecture&lt;/li&gt;
&lt;li&gt;performance review&lt;/li&gt;
&lt;li&gt;data review&lt;/li&gt;
&lt;li&gt;development&lt;/li&gt;
&lt;li&gt;code review&lt;/li&gt;
&lt;li&gt;accessibility review&lt;/li&gt;
&lt;li&gt;security review&lt;/li&gt;
&lt;li&gt;QA&lt;/li&gt;
&lt;li&gt;SRE review&lt;/li&gt;
&lt;li&gt;documentation&lt;/li&gt;
&lt;li&gt;DevOps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the first few steps load the wrong material, every later agent pays for it.&lt;/p&gt;

&lt;p&gt;The important design move is that the context manifest is itself a governed artifact. It has a contract in &lt;code&gt;shared/contracts/context-manifest-contract.md&lt;/code&gt;, and the &lt;code&gt;validate-artifact&lt;/code&gt; skill checks that required sections are present before the pipeline moves forward.&lt;/p&gt;

&lt;p&gt;Context is not just "whatever the chat accumulated."&lt;/p&gt;

&lt;p&gt;It is an explicit handoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context, memory, and learning are different problems
&lt;/h2&gt;

&lt;p&gt;One of the most useful distinctions in the repo lives in &lt;code&gt;docs/runbooks/context-engineering.md&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Context is what is loaded into the model right now.&lt;/li&gt;
&lt;li&gt;Memory is durable knowledge that outlives the current run.&lt;/li&gt;
&lt;li&gt;Learning is a feedback loop that changes future behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;People often flatten all three into "RAG."&lt;/p&gt;

&lt;p&gt;That loses important design pressure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context&lt;/strong&gt; is a working set. It should be small, relevant, and current.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory&lt;/strong&gt; is a durable corpus. It should be curated, searchable, and allowed to expire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Learning&lt;/strong&gt; is a loop. It should turn repeated delivery evidence into changed rules, changed prompts, or new Knowledge Items.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;context-engineer&lt;/code&gt; reads memory to build better context, but it does not automatically rewrite memory.&lt;br&gt;
That separation keeps a bad or noisy run from polluting the durable layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context decay
&lt;/h2&gt;

&lt;p&gt;The framework also uses "context decay."&lt;/p&gt;

&lt;p&gt;An artifact more than two pipeline phases old should be read as a summary, not in full. The target is a small gist, roughly 200 words, produced through the &lt;code&gt;summarize-artifact&lt;/code&gt; skill.&lt;/p&gt;

&lt;p&gt;That is an intentionally boring mechanism, and that is why I like it.&lt;/p&gt;

&lt;p&gt;Most context-window failures do not need a magic retrieval system. They need fewer stale artifacts loaded verbatim.&lt;/p&gt;

&lt;p&gt;If the developer is five phases downstream from the analyst, they probably need the current acceptance criteria, edge cases, and constraints. They do not need every sentence of the analyst's intermediate reasoning still floating in the model's attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform reality changes the design
&lt;/h2&gt;

&lt;p&gt;The repo does not pretend every AI coding tool has the same capabilities.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt; defines a capability tier system. Claude Code has full agent orchestration. Cursor&lt;br&gt;
now has real &lt;code&gt;.cursor/agents/&lt;/code&gt; and &lt;code&gt;.cursor/skills/&lt;/code&gt; loading, but its rule files still need fully inlined&lt;br&gt;
content. Windsurf and Copilot get persona/rule projections. Gemini/Antigravity reads &lt;code&gt;AGENTS.md&lt;/code&gt; and has&lt;br&gt;
confirmed skill invocation. Codex gets an inlined &lt;code&gt;.openai.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means the framework has to distinguish "agent" from "persona."&lt;/p&gt;

&lt;p&gt;An agent can have tool access and participate in a multi-step process. A persona is a context frame: useful,&lt;br&gt;
but not autonomous.&lt;/p&gt;

&lt;p&gt;The practical result is a &lt;code&gt;shared/&lt;/code&gt; canonical layer, plus generation and parity checks for each platform.&lt;br&gt;
&lt;code&gt;scripts/check-parity.sh&lt;/code&gt; exists because hand-copying instructions across tools is how drift wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would copy into another project
&lt;/h2&gt;

&lt;p&gt;If you do not need a 24-agent framework, I would still steal these ideas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write down the difference between Context, Memory, and Learning.&lt;/li&gt;
&lt;li&gt;Add a pre-flight context manifest before complex work starts.&lt;/li&gt;
&lt;li&gt;Treat old artifacts as summaries by default.&lt;/li&gt;
&lt;li&gt;Make context handoffs structural, not conversational.&lt;/li&gt;
&lt;li&gt;Add a parity check for any instruction copied across multiple tools.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The context window is not just a bigger prompt.&lt;br&gt;
It is a scarce design surface.&lt;br&gt;
Use it like one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source trail
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;README.md&lt;/code&gt; — canonical &lt;code&gt;shared/&lt;/code&gt; layer, 24-agent roster, 53-skill catalog, six platform targets.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt; — capability tiers, platform projection model, and context flow.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/runbooks/context-engineering.md&lt;/code&gt; — Context/Memory/Learning distinction, context decay, manifest role.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/AGENT_REFERENCE.md&lt;/code&gt; — &lt;code&gt;context-engineer&lt;/code&gt; role and counterbalances.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs/features/context-engineering-framework/TODO.md&lt;/code&gt; — Epic 5 contract work and Epic 23 contract closure.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>coding</category>
      <category>llm</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Making LLM outputs auditable: the provider abstraction pattern</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:28:08 +0000</pubDate>
      <link>https://dev.to/orieken/making-llm-outputs-auditable-the-provider-abstraction-pattern-5c7e</link>
      <guid>https://dev.to/orieken/making-llm-outputs-auditable-the-provider-abstraction-pattern-5c7e</guid>
      <description>&lt;h2&gt;
  
  
  The problem with calling an LLM directly
&lt;/h2&gt;

&lt;p&gt;NumPath's teacher dashboard generates per-student insights — one-sentence observations like "Emma skips borrowing in 9 of 11 recent subtraction attempts" with a suggested action. The obvious implementation is to import the Anthropic SDK, call &lt;code&gt;messages.create()&lt;/code&gt;, and return the result.&lt;/p&gt;

&lt;p&gt;That works until you need to test it. Or run it offline. Or swap providers. Or audit where the insight came from.&lt;/p&gt;

&lt;p&gt;This post covers how NumPath abstracts the LLM behind a protocol interface, tests with a deterministic stub, and structures the insight pipeline so the evidence is assembled from database reads — not generated by the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Protocol: 6 lines
&lt;/h2&gt;

&lt;p&gt;The entire LLM abstraction is a Python &lt;code&gt;Protocol&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runtime_checkable&lt;/span&gt;

&lt;span class="nd"&gt;@runtime_checkable&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LLMProvider&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No base class. No ABC. No framework. Any object with an &lt;code&gt;async def complete(self, system, user, max_tokens)&lt;/code&gt; method satisfies this interface — that's structural typing via &lt;code&gt;Protocol&lt;/code&gt;. The &lt;code&gt;@runtime_checkable&lt;/code&gt; decorator lets you write &lt;code&gt;isinstance(provider, LLMProvider)&lt;/code&gt; if you need a runtime check, though in practice the type checker catches mismatches at lint time.&lt;/p&gt;

&lt;p&gt;The signature is deliberately narrow: one system prompt, one user message, one token limit. No conversation history, no tool use, no streaming. NumPath's insight generator makes a single completion call per request. If multi-turn conversation becomes necessary in Phase 3, the protocol gains a new method — existing implementations aren't broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two implementations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ClaudeProvider&lt;/strong&gt; — the production implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClaudeProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncAnthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;StubProvider&lt;/strong&gt; — deterministic, zero dependencies, zero API calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StubProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Deterministic LLM stub for tests and local dev without API keys.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Student is building foundational numeracy skills &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;with consistent effort.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;suggested_action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Try place value &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
            &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;exercises with physical manipulatives to reinforce digit positioning.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stub returns a fixed JSON string that matches the expected response schema. Tests assert against this exact output. If someone changes the response schema, the stub breaks, the tests break, and the problem is caught before deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring: one environment variable
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_llm_provider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;LLMProvider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LLM_PROVIDER&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ClaudeProvider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StubProvider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LLM_PROVIDER&lt;/code&gt; defaults to &lt;code&gt;"stub"&lt;/code&gt;. Running &lt;code&gt;uv run pytest&lt;/code&gt; requires zero environment variables — no API key, no network. Production sets &lt;code&gt;LLM_PROVIDER=claude&lt;/code&gt; and provides &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;. The config uses &lt;code&gt;Literal["claude", "stub"]&lt;/code&gt; so a typo like &lt;code&gt;"Claude"&lt;/code&gt; fails at startup.&lt;/p&gt;

&lt;p&gt;The use case receives the provider through its constructor, not through a global:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GenerateInsightUseCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LLMProvider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The router wires it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@router.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/students/{student_id}/insight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InsightResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_student_insight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;require_teacher&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;InsightResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_llm_provider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;use_case&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GenerateInsightUseCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;use_case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Evidence is not generated — it's assembled
&lt;/h2&gt;

&lt;p&gt;This is the design decision that matters most for a research project. When a teacher sees an insight, they need to trust it — and "trust" in an educational context means "I can check this against the data."&lt;/p&gt;

&lt;p&gt;The insight prompt receives two blocks of structured data, both assembled from database queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KC states:
- SUB_BORROW: Novice (p_mastery=0.18, 8 attempts)
- PLACE_VALUE: Developing (p_mastery=0.45, 3 attempts)
- NUMBER_LINE: Novice (p_mastery=0.15, 1 attempt)

Recent attempts (last 10, most recent first):
1. Skill: SUB_BORROW | Correct: No | Mistake: BORROW_SKIP | Q: "52 − 27 = ?"
2. Skill: SUB_BORROW | Correct: No | Mistake: BORROW_SKIP | Q: "31 − 14 = ?"
3. Skill: PLACE_VALUE | Correct: Yes | Mistake: none | Q: "Which is larger: 47 or 74?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LLM generates two fields: &lt;code&gt;summary&lt;/code&gt; (what's happening) and &lt;code&gt;suggested_action&lt;/code&gt; (what to do). It does &lt;em&gt;not&lt;/em&gt; generate the evidence — the KC codes, mastery percentages, mistake counts, and attempt records are all server-side data. The LLM synthesises a narrative from that data, but the data itself is verifiable.&lt;/p&gt;

&lt;p&gt;The prompt enforces this structurally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are a specialist math learning advisor for primary school teachers.
Given their Knowledge Component mastery states and recent attempt history,
generate a JSON response with exactly two fields:
- "summary": one sentence (max 20 words) describing the student's current learning state
- "suggested_action": one concrete teaching action (max 20 words) the teacher can take today

Respond with only the JSON object. No explanation, no markdown, no code fences.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strict JSON. Word limits. No room for hallucinated statistics or invented KC codes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Graceful fallback
&lt;/h2&gt;

&lt;p&gt;LLMs produce unpredictable output. The response parser handles malformed JSON without crashing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;_FALLBACK_INSIGHT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InsightResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Insight temporarily unavailable.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;suggested_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Review the student&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s recent attempts for patterns.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_parse_insight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;InsightResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;InsightResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;suggested_action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;suggested_action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;KeyError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insight_parse_failed_using_fallback raw=%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_FALLBACK_INSIGHT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback is a valid &lt;code&gt;InsightResponse&lt;/code&gt; — the teacher sees a neutral message, not a 500 error. The warning log captures the first 200 characters of the raw response for debugging without logging the entire LLM output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not LangChain?
&lt;/h2&gt;

&lt;p&gt;This was an explicit decision, documented in ADR-003. LangChain adds 50+ transitive dependencies and significant abstraction cost for what NumPath actually needs: one completion call with a system prompt and a user message. The protocol-based approach is 6 lines of interface, 8 lines of stub, 9 lines of production implementation. The total abstraction surface is smaller than LangChain's &lt;code&gt;ChatModel&lt;/code&gt; base class alone.&lt;/p&gt;

&lt;p&gt;If NumPath needed retrieval-augmented generation, multi-step chains, or agent loops, LangChain would earn its weight. For two structured completion calls (insight generation and hint narration), it would be accidental complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fitness function
&lt;/h2&gt;

&lt;p&gt;ADR-003 specifies a concrete test: &lt;code&gt;uv run pytest&lt;/code&gt; must pass using &lt;code&gt;StubProvider&lt;/code&gt; with no environment variables set. This means every LLM-dependent code path has a test that runs offline. If someone adds a new LLM feature and writes a test that requires &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;, CI fails — not because the test is wrong, but because it violates the architectural constraint that the test suite runs without external dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The current provider interface handles single-turn completions. Phase 3 may need multi-turn conversation for interactive teacher coaching. When that happens, the protocol gains a second method — &lt;code&gt;complete()&lt;/code&gt; stays unchanged, and a new &lt;code&gt;converse()&lt;/code&gt; method handles the multi-turn case. Existing implementations get a &lt;code&gt;NotImplementedError&lt;/code&gt; default until they're updated. The key is that the interface extends forward without breaking backward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Protocol-based abstraction costs 6 lines and buys full test isolation&lt;/strong&gt; — &lt;code&gt;StubProvider&lt;/code&gt; returns deterministic output; no API key, no network, no flaky tests; the type checker enforces the contract at lint time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence must be assembled from data, not generated by the model&lt;/strong&gt; — the LLM writes the narrative but doesn't produce the numbers; KC codes, mastery percentages, and mistake counts come from database queries and are independently verifiable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful fallback is a first-class design requirement&lt;/strong&gt; — a teacher sees "insight temporarily unavailable" and a neutral suggestion, never a stack trace; the warning log captures the raw output for debugging without exposing it to the user&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>numpath</category>
      <category>python</category>
      <category>llm</category>
      <category>claude</category>
    </item>
    <item>
      <title>60 hand-crafted math problems: what I learned writing seed data for an adaptive tutor</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:27:18 +0000</pubDate>
      <link>https://dev.to/orieken/60-hand-crafted-math-problems-what-i-learned-writing-seed-data-for-an-adaptive-tutor-2dob</link>
      <guid>https://dev.to/orieken/60-hand-crafted-math-problems-what-i-learned-writing-seed-data-for-an-adaptive-tutor-2dob</guid>
      <description>&lt;h2&gt;
  
  
  Why hand-author anything?
&lt;/h2&gt;

&lt;p&gt;The obvious approach for seeding an adaptive math tutor is to generate problems programmatically. Pick two random numbers, subtract them, done. I tried this first and it failed for a specific reason: generated problems don't have meaningful hints.&lt;/p&gt;

&lt;p&gt;A hint like "Try subtracting the ones column first" is generic. A hint like "2 ones minus 9 is impossible without borrowing — take a ten from the 3 tens" is diagnostic. It names the exact step where a dyscalculic student is likely to get stuck, and it names the operation they need to perform. That second kind of hint requires a human who understands the problem.&lt;/p&gt;

&lt;p&gt;NumPath's Phase 1 seeds 100 problems across 5 Knowledge Components, each with two progressive hints, a calibrated difficulty score, and structured metadata that the &lt;code&gt;MistakeClassifier&lt;/code&gt; uses to diagnose errors. Every one is hand-authored.&lt;/p&gt;

&lt;h2&gt;
  
  
  The content schema
&lt;/h2&gt;

&lt;p&gt;Each problem is a JSONB column in Postgres. The schema is intentionally flat — no nested objects, no polymorphism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subtraction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;32 − 9 = ?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;23&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;difficulty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;operands&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hints&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2 ones − 9 is impossible without borrowing. Take a ten from the 3 tens.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Now you have 12 ones. 12 − 9 = 3. You have 2 tens left. Answer: 23.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three fields deserve explanation:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;operands&lt;/code&gt; / &lt;code&gt;choices&lt;/code&gt;&lt;/strong&gt; — these aren't shown to the student. They exist for the &lt;code&gt;MistakeClassifier&lt;/code&gt;. When a student answers "41" instead of "23" on a subtraction problem, the classifier checks whether the answer matches subtracting the digits in the wrong direction (&lt;code&gt;3 - 2 = 1&lt;/code&gt;, &lt;code&gt;9 - 0 = 9&lt;/code&gt; → &lt;code&gt;91&lt;/code&gt;... no). It checks whether the answer omits borrowing (&lt;code&gt;32 - 9&lt;/code&gt; without regrouping gives &lt;code&gt;33&lt;/code&gt;... no). It checks for digit reversal (&lt;code&gt;23&lt;/code&gt; → &lt;code&gt;32&lt;/code&gt;... close, but the student wrote &lt;code&gt;41&lt;/code&gt;). Each check operates on the operands, not the question string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;difficulty&lt;/code&gt;&lt;/strong&gt; — a float from 0.1 to 0.9, calibrated by hand. This is the initial difficulty estimate. The adaptive engine uses it to match students to problems at their current level. I'll explain the calibration logic below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;hints&lt;/code&gt;&lt;/strong&gt; — always exactly two, always progressive. The first hint names the obstacle. The second hint walks through the solution. Students reveal hints one at a time, voluntarily. Hints are never forced — forcing hints on students who don't want them creates learned helplessness, which is the opposite of what we're trying to study.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five skill areas
&lt;/h2&gt;

&lt;p&gt;Each skill has 20 problems covering a difficulty gradient from 0.1 to 0.9:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Skill Code&lt;/th&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;Example at 0.1&lt;/th&gt;
&lt;th&gt;Example at 0.9&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SUB_BORROW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subtraction&lt;/td&gt;
&lt;td&gt;11 − 4 = ?&lt;/td&gt;
&lt;td&gt;1003 − 567 = ?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PLACE_VALUE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number sense&lt;/td&gt;
&lt;td&gt;Which is larger: 3 or 8?&lt;/td&gt;
&lt;td&gt;What does the 6 represent in 3,641?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NUMBER_LINE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number sense&lt;/td&gt;
&lt;td&gt;What number comes after 3?&lt;/td&gt;
&lt;td&gt;What is halfway between 250 and 350?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NUMBER_SENSE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number sense&lt;/td&gt;
&lt;td&gt;Which is more: 2 or 5?&lt;/td&gt;
&lt;td&gt;Order from smallest: 892, 829, 928, 289&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OPERATION_SIGN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Arithmetic&lt;/td&gt;
&lt;td&gt;2 + 3 = ?&lt;/td&gt;
&lt;td&gt;15 − 7 + 3 = ?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The difficulty gradient is not linear. The jump from 0.1 to 0.3 (single-digit to simple two-digit) is smaller than the jump from 0.7 to 0.9 (two-digit with borrowing across zeros to three-digit with cascading borrows). This mirrors what the dyscalculia research literature reports: difficulty is not proportional to number size. It's proportional to the number of cognitive steps, particularly steps that require regrouping or holding intermediate results in working memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hint design: what I got wrong
&lt;/h2&gt;

&lt;p&gt;My first draft of hints was procedural — they described &lt;em&gt;what&lt;/em&gt; to do:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Borrow from the tens column. Subtract. Write the answer."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is useless for a student with dyscalculia. The difficulty isn't knowing &lt;em&gt;what&lt;/em&gt; borrowing is — it's executing the procedure without losing track of which column they're in. The second draft of every hint follows two rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Name the specific obstacle.&lt;/strong&gt; Not "this is tricky" — rather "2 ones minus 9 is impossible without borrowing."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk through the state change.&lt;/strong&gt; Not "borrow and subtract" — rather "Take a ten from the 3 tens. Now you have 12 ones. 12 − 9 = 3. You have 2 tens left."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second rule matters because dyscalculic students often lose the intermediate state — they borrow correctly but then forget what changed. The hint reconstructs the full number after regrouping so the student can see where they are.&lt;/p&gt;

&lt;p&gt;This pattern held across all five skill areas. Place value hints name the specific column ("the tens digit is the second from the right"). Number line hints name the direction and distance ("7 is to the right of 4 — count 3 steps forward"). Operation sign hints name the symbols and their meaning ("the − sign means subtract — take the second number away from the first").&lt;/p&gt;

&lt;h2&gt;
  
  
  Difficulty calibration
&lt;/h2&gt;

&lt;p&gt;Difficulty scores are not arbitrary. They follow a rubric I developed after the first round of testing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Score range&lt;/th&gt;
&lt;th&gt;Criteria&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.1 – 0.2&lt;/td&gt;
&lt;td&gt;Single-digit or simple two-digit; one cognitive step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.25 – 0.4&lt;/td&gt;
&lt;td&gt;Two-digit; requires one borrowing or comparison step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.45 – 0.6&lt;/td&gt;
&lt;td&gt;Two-digit with borrowing across columns, or three-digit without borrowing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.65 – 0.8&lt;/td&gt;
&lt;td&gt;Three-digit with borrowing; or problems requiring intermediate computation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.85 – 0.9&lt;/td&gt;
&lt;td&gt;Three-digit with cascading borrows (e.g., borrowing from hundreds when tens is 0)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The adaptive engine uses a &lt;code&gt;DIFFICULTY_BAND&lt;/code&gt; of 0.15 around the target difficulty when selecting problems. So a student at target difficulty 0.5 sees problems between 0.35 and 0.65. This means each difficulty tier overlaps with its neighbors — a student improving from 0.4 to 0.6 transitions gradually rather than hitting a cliff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seed script
&lt;/h2&gt;

&lt;p&gt;The seed is idempotent — safe to run on every deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;seed_problems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skill_id_map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;skill_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;problems&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PROBLEMS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;skill_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;skill_id_map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;skill_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;difficulty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nf"&gt;pg_insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Problem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;skill_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;skill_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;difficulty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;difficulty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="n"&gt;problem_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on_conflict_do_nothing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;on_conflict_do_nothing()&lt;/code&gt; means re-running the seed doesn't duplicate problems. The &lt;code&gt;difficulty&lt;/code&gt; field is stored both inside the JSONB &lt;code&gt;content&lt;/code&gt; and as a top-level column on the &lt;code&gt;Problem&lt;/code&gt; model — the column is indexed for the adaptive engine's range queries, while the JSONB copy preserves the original specification.&lt;/p&gt;

&lt;p&gt;The full seed runs inside a single transaction: skills first (because problems have a foreign key to skills), then problems, then test accounts. If any step fails, nothing is committed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Two things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;More problems per skill.&lt;/strong&gt; Twenty problems with a 0.15 difficulty band means some bands have only 2–3 candidates. When the adaptive engine excludes recently-seen problems, it can run out of fresh options at a specific difficulty level. The fallback chain handles this gracefully (widen the band, then allow repeats), but 30 problems per skill would eliminate most fallback cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Machine-assisted hint generation.&lt;/strong&gt; The hints are the bottleneck — each one took 2–3 minutes to write well. For Phase 2, I plan to generate candidate hints with Claude and then manually review them. The human is still in the loop, but the first draft comes faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generated problems are easy; generated hints are not&lt;/strong&gt; — an adaptive tutor's value is in the scaffolding, not the arithmetic; hand-authoring hints that name the specific obstacle and walk through the state change is what makes the system useful for dyscalculia&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Difficulty is not proportional to number size&lt;/strong&gt; — it's proportional to cognitive steps, particularly regrouping and intermediate state; a three-digit problem with no borrowing (350 − 120) is easier than a two-digit problem with cascading borrows (100 − 67)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotent seeds inside a transaction are non-negotiable&lt;/strong&gt; — &lt;code&gt;on_conflict_do_nothing()&lt;/code&gt; plus a single transaction means the seed runs safely on every deployment, fresh clone, and CI pipeline&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>numpath</category>
      <category>dyscalculia</category>
      <category>python</category>
      <category>education</category>
    </item>
    <item>
      <title>Clean Architecture in a FastAPI + Vue 3 monorepo</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:26:44 +0000</pubDate>
      <link>https://dev.to/orieken/clean-architecture-in-a-fastapi-vue-3-monorepo-38p3</link>
      <guid>https://dev.to/orieken/clean-architecture-in-a-fastapi-vue-3-monorepo-38p3</guid>
      <description>&lt;h2&gt;
  
  
  Why architecture matters in a research project
&lt;/h2&gt;

&lt;p&gt;Most research prototypes are throwaway code. NumPath is not. It needs to survive four phases over 30 weeks, accumulate real student data for a randomised controlled trial, and remain testable without live infrastructure at every step. That means the architecture has to enforce rules that hold up under pressure — not just conventions someone remembers to follow.&lt;/p&gt;

&lt;p&gt;This post walks through how NumPath uses Clean Architecture to keep a FastAPI backend, a Vue 3 frontend, and a Python ML module in a single repository without coupling them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The monorepo layout
&lt;/h2&gt;

&lt;p&gt;The project lives in a single repo with a clear namespace boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;phd-research/
├── numpath/
│   ├── backend/      # Python 3.12 + FastAPI + SQLAlchemy
│   ├── frontend/     # Vue 3 + Tailwind CSS + Pinia
│   └── ml/           # BKT, DKT, adaptive engine
├── docs/
│   ├── adrs/         # Architecture Decision Records
│   ├── architecture/ # System design, feature specs
│   └── posts/        # This blog series
└── DOMAIN_DICTIONARY.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alternative was three separate repos. For a solo PhD project where a data model change touches the migration, the API schema, the ML engine, and the Vue component in the same commit, separate repos mean coordinated PRs across three remotes. That's overhead with no benefit when one person owns all three layers.&lt;/p&gt;

&lt;p&gt;The escape hatch is clean: if NumPath ever needs to become a standalone repo, the &lt;code&gt;numpath/&lt;/code&gt; directory lifts out intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dependency direction: the one rule that matters
&lt;/h2&gt;

&lt;p&gt;Clean Architecture has many principles, but only one that I enforce mechanically: &lt;strong&gt;inner layers never import from outer layers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In NumPath's backend, the layers are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Domain (models)  →  Use Cases  →  Adapters (routers, DB, LLM)  →  Frameworks (FastAPI, SQLAlchemy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A use case like &lt;code&gt;GetNextProblemUseCase&lt;/code&gt; receives a database session — but it does not import FastAPI, does not know about HTTP, and does not call &lt;code&gt;Depends()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GetNextProblemUseCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NextProblemResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;kc_states&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_build_kc_states&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;recent_attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_fetch_recent_attempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;recent_mistakes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_fetch_recent_mistakes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ProblemSelection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select_next_problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;kc_states&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;kc_states&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;recent_correctness&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_correct&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;recent_attempts&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;current_difficulty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;recent_attempts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;difficulty&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;recent_attempts&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;recent_mistakes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;recent_mistakes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;problem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_select_problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
        &lt;span class="c1"&gt;# ... return NextProblemResponse
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The router — the adapter layer — is the only file that knows about FastAPI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@router.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/next-problem/{student_id}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NextProblemResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_next_problem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;require_student&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NextProblemResponse&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;use_case&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;GetNextProblemUseCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;use_case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The router does three things: parse the request, inject dependencies, and delegate to the use case. No business logic. If I replaced FastAPI with Litestar tomorrow, I'd rewrite the routers and touch nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration as a boundary
&lt;/h2&gt;

&lt;p&gt;Settings are another place where framework details leak into domain code if you're not careful. NumPath uses Pydantic's &lt;code&gt;BaseSettings&lt;/code&gt; with a &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;model_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SettingsConfigDict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env_file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.env&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql+asyncpg://numpath:numpath@localhost:5432/numpath&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LLM_PROVIDER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;ENVIRONMENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;development&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;production&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;development&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;CORS_ORIGINS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:5173&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every secret has a default that works locally. &lt;code&gt;LLM_PROVIDER&lt;/code&gt; defaults to &lt;code&gt;"stub"&lt;/code&gt; so that tests and local dev never require an API key. The &lt;code&gt;Literal&lt;/code&gt; type annotation means a typo in the &lt;code&gt;.env&lt;/code&gt; file fails at startup, not at runtime when a teacher clicks "Generate insight."&lt;/p&gt;

&lt;h2&gt;
  
  
  The ML module as a pure function boundary
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ml/&lt;/code&gt; directory is a separate Python package (&lt;code&gt;numpath-ml&lt;/code&gt;) with its own &lt;code&gt;pyproject.toml&lt;/code&gt;. The backend depends on it, but the dependency is narrow: two functions and a dataclass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;numpath_ml.adaptive_engine&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;select_next_problem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ProblemSelection&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;numpath_ml.bkt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;KCState&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;select_next_problem()&lt;/code&gt; takes dictionaries and lists — no SQLAlchemy models, no async, no database. It returns a &lt;code&gt;ProblemSelection&lt;/code&gt; with a &lt;code&gt;skill_code&lt;/code&gt;, &lt;code&gt;target_difficulty&lt;/code&gt;, and &lt;code&gt;reason&lt;/code&gt; string. The use case translates between database rows and these pure data structures.&lt;/p&gt;

&lt;p&gt;This boundary exists because the ML code changes on a different cadence than the web application. When I replace the rule-based engine with Deep Knowledge Tracing in Phase 2, the use case stays the same — only the function it calls changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The frontend: same principle, different language
&lt;/h2&gt;

&lt;p&gt;The Vue 3 frontend mirrors the same layering:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API client&lt;/strong&gt; — a thin Axios wrapper that handles auth tokens and 401 redirects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;apiClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Authorization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stores&lt;/strong&gt; — Pinia stores manage state. The auth store handles login/logout and persists the JWT to localStorage. Views consume stores, not the API client directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Views&lt;/strong&gt; — &lt;code&gt;PracticeView.vue&lt;/code&gt;, &lt;code&gt;TeacherView.vue&lt;/code&gt;, &lt;code&gt;LoginView.vue&lt;/code&gt;. Each view composes API calls and store access. No view imports another view.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Router&lt;/strong&gt; — role-based guards redirect students and teachers to their respective views:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;beforeEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAuthStore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiresAuth&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;teacher&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/teacher&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/practice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Docker Compose as the integration layer
&lt;/h2&gt;

&lt;p&gt;The four services — Postgres, Redis, backend, frontend — are composed with health checks so the backend waits for the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql+asyncpg://numpath:numpath@postgres:5432/numpath&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./backend:/app/backend&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./ml:/app/ml&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv run uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Volume-mounting &lt;code&gt;backend/&lt;/code&gt; and &lt;code&gt;ml/&lt;/code&gt; means hot reload works inside Docker — change a use case, save, and the server restarts. The port mapping (&lt;code&gt;5433:5432&lt;/code&gt; for Postgres) avoids collisions with a local Postgres install.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys you
&lt;/h2&gt;

&lt;p&gt;Three concrete benefits I've already seen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test isolation&lt;/strong&gt; — use cases are testable with a real async database session and no HTTP server. The test creates a &lt;code&gt;GetNextProblemUseCase(db)&lt;/code&gt; directly. No FastAPI test client needed for business logic tests.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LLM swappability&lt;/strong&gt; — &lt;code&gt;GenerateInsightUseCase&lt;/code&gt; receives an &lt;code&gt;LLMProvider&lt;/code&gt; protocol. In tests it gets &lt;code&gt;StubProvider&lt;/code&gt;. In production it gets &lt;code&gt;ClaudeProvider&lt;/code&gt;. The use case doesn't know which one it has.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Safe ML replacement&lt;/strong&gt; — when BKT gives way to DKT, only &lt;code&gt;numpath_ml&lt;/code&gt; changes. The use case calls the same &lt;code&gt;select_next_problem()&lt;/code&gt; function with the same signature. The router doesn't change. The frontend doesn't change.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I wouldn't change the layering, but I'd add one thing from day one: a fitness function that statically checks import direction. Right now the rule is "use cases don't import routers" — but it's enforced by code review (i.e., me reviewing my own code). A linter rule or CI check that fails on &lt;code&gt;from backend.routers&lt;/code&gt; inside &lt;code&gt;use_cases/&lt;/code&gt; would catch drift automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dependency direction is the only architectural rule worth enforcing mechanically&lt;/strong&gt; — inner layers never import outer layers; everything else is convention that erodes under deadline pressure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A monorepo is the right default for a solo research project&lt;/strong&gt; — coordinated PRs across three repos is overhead without benefit when one person owns all layers and changes cut across them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pure function boundaries between modules pay for themselves&lt;/strong&gt; — the ML module exports two functions and a dataclass; the web layer translates between database rows and those pure structures, making the ML code replaceable without touching the application&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>numpath</category>
      <category>cleanarchitecture</category>
      <category>python</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>From Bayesian to deep knowledge tracing — upgrading NumPath's student model with a PyTorch LSTM</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:26:00 +0000</pubDate>
      <link>https://dev.to/orieken/from-bayesian-to-deep-knowledge-tracing-upgrading-numpaths-student-model-with-a-pytorch-lstm-2ikm</link>
      <guid>https://dev.to/orieken/from-bayesian-to-deep-knowledge-tracing-upgrading-numpaths-student-model-with-a-pytorch-lstm-2ikm</guid>
      <description>&lt;p&gt;BKT told us how well a student knows subtraction-with-borrowing. It had no idea that a student who reverses digits on subtraction problems probably also reverses them on place value problems — because BKT treats every Knowledge Component as an island.&lt;/p&gt;

&lt;p&gt;Deep Knowledge Tracing (DKT) fixes that. Instead of four independent scalar parameters per KC, it maintains a shared LSTM hidden vector across all KCs and learns the dependencies from data. This is Phase 3 of NumPath: swapping out the Markov model for a neural sequence model.&lt;/p&gt;

&lt;p&gt;Here's what we built, the design decision that almost made us reach for a transformer, and the student simulator we had to build first to test it without any real students.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;Two components that feed each other:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Student simulator&lt;/strong&gt; — five named personas that generate realistic attempt sequences for testing. Each persona has a per-KC accuracy curve and weighted mistake preferences drawn from the dyscalculia ITS literature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Persona&lt;/th&gt;
&lt;th&gt;SUB_BORROW accuracy&lt;/th&gt;
&lt;th&gt;Characteristic errors&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ConfidentLearner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.80&lt;/td&gt;
&lt;td&gt;Rare, careless (OFF_BY_TEN)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;StrugglingSUB&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;td&gt;Frequent BORROW_SKIP, slow timing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PlaceValueGap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.60&lt;/td&gt;
&lt;td&gt;DIGIT_REVERSAL across skill areas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FrustrationLoop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;td&gt;Fast random guessing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FastMaster&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.90&lt;/td&gt;
&lt;td&gt;Near-zero mistakes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;DKT model&lt;/strong&gt; — a single-layer LSTM that takes a sequence of &lt;code&gt;(skill, correctness)&lt;/code&gt; interactions and predicts P(correct on skill k) at each subsequent step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DKTModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_skills&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hidden_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initial_state&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# Student answers SUB_BORROW correctly
&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skill_idx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_correct&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Query mastery on any KC
&lt;/span&gt;&lt;span class="n"&gt;p_mastery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skill_idx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# → float in (0, 1)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Design Decision
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why not stay with BKT?
&lt;/h3&gt;

&lt;p&gt;BKT's four parameters — p_mastery, p_learn, p_guess, p_slip — are per-KC and independent. A student who has &lt;code&gt;DIGIT_REVERSAL&lt;/code&gt; on subtraction problems and &lt;code&gt;DIGIT_REVERSAL&lt;/code&gt; on place value problems is modelled as having two unrelated problems. BKT cannot learn that these are the same underlying representational gap.&lt;/p&gt;

&lt;p&gt;DKT's hidden state is shared. After the student makes a digit-reversal error on subtraction, the LSTM adjusts its hidden vector in a way that also shifts the place value prediction. It learns the cross-KC structure from data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not a transformer?
&lt;/h3&gt;

&lt;p&gt;The sequence lengths we're working with are short — 10 to 30 attempts per session. Transformers need longer sequences to exploit their attention mechanism meaningfully. An LSTM is a better fit: it handles variable-length sequences natively, trains faster on small datasets, and produces interpretable per-step hidden states we can inspect.&lt;/p&gt;

&lt;p&gt;More importantly: the Piech et al. (2015) DKT paper established LSTMs as the baseline for knowledge tracing. Improving on the baseline is Phase 4 work; Phase 3 is implementing it correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  The encoding
&lt;/h3&gt;

&lt;p&gt;The input encoding follows Piech et al. exactly. At each step t, the input is a one-hot vector of size &lt;code&gt;2 × n_skills&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;x[k]             = 1  if skill k was answered CORRECTLY
x[k + n_skills]  = 1  if skill k was answered INCORRECTLY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For three skills (SUB_BORROW=0, PLACE_VALUE=1, NUMBER_LINE=2), a correct subtraction answer encodes as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1, 0, 0,  0, 0, 0]
  ↑ correct half    ↑ incorrect half
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An incorrect subtraction answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[0, 0, 0,  1, 0, 0]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LSTM sees this 6-dimensional input and updates its hidden state. The output layer projects the hidden state back to 3 dimensions — one P(correct) per KC.&lt;/p&gt;

&lt;h2&gt;
  
  
  The training objective
&lt;/h2&gt;

&lt;p&gt;The model learns to predict the NEXT response from the current history. At step t, given the encoded interaction x_t, the LSTM outputs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ŷ_t[k] = σ(W × h_t + b)[k]  =  P(student answers skill k correctly at t+1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loss at each step uses only the skill that was actually asked next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# At step t, the next question has skill_idx q and correctness r
&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tensor&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;span class="n"&gt;pred&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;unsqueeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;loss_t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pred&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Training is one sequence at a time with Adam and gradient clipping. Small dataset — no need for batching yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the simulator came first
&lt;/h2&gt;

&lt;p&gt;We can't train DKT on real data until the pilot delivers ≥150 attempt records. But we can validate the architecture right now with the student simulator.&lt;/p&gt;

&lt;p&gt;The final integration test runs both pipelines end to end:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Generate 30 sequences from &lt;code&gt;StrugglingSUB&lt;/code&gt; (35% accuracy on SUB_BORROW)&lt;/li&gt;
&lt;li&gt;Generate 30 sequences from &lt;code&gt;FastMaster&lt;/code&gt; (90% accuracy on SUB_BORROW)&lt;/li&gt;
&lt;li&gt;Train two separate DKT models on each persona's sequences&lt;/li&gt;
&lt;li&gt;Simulate 6 practice steps with each model&lt;/li&gt;
&lt;li&gt;Assert &lt;code&gt;FastMaster&lt;/code&gt;'s model predicts higher mastery than &lt;code&gt;StrugglingSUB&lt;/code&gt;'s
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# From test_dkt.py
&lt;/span&gt;&lt;span class="n"&gt;fast_mastery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mastery_after_steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_fast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;    &lt;span class="c1"&gt;# 5/6 correct
&lt;/span&gt;
&lt;span class="n"&gt;struggling_mastery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mastery_after_steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_struggling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="c1"&gt;# 2/6 correct
&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;fast_mastery&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;struggling_mastery&lt;/span&gt;      &lt;span class="c1"&gt;# ✓ passes
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives us confidence the model learns the right signal before we hand it real children's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Matters for the Research
&lt;/h2&gt;

&lt;p&gt;BKT's independence assumption is a known limitation in the ITS literature. It was acceptable for Phase 1 and 2 because we didn't have cross-KC interaction data. Now that the mistake classifier is generating &lt;code&gt;BORROW_SKIP&lt;/code&gt; and &lt;code&gt;DIGIT_REVERSAL&lt;/code&gt; events consistently, we have a sequence model that can learn from them.&lt;/p&gt;

&lt;p&gt;The specific research claim that DKT enables: &lt;strong&gt;a student's error pattern on one KC predicts their likely error pattern on a related KC&lt;/strong&gt;. If DKT learns this and BKT doesn't, that's measurable evidence that the LSTM captures structure that the Markov model misses — and a direct contribution to the Phase 4 RCT analysis.&lt;/p&gt;

&lt;p&gt;The upgrade path is explicit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pilot delivers ≥150 attempts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;train_dkt(sequences_from_db)&lt;/code&gt; on the full dataset&lt;/li&gt;
&lt;li&gt;Evaluate against BKT's predictions using held-out sessions&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;update_bkt&lt;/code&gt; in &lt;code&gt;SubmitAttemptUseCase&lt;/code&gt; when DKT's per-KC accuracy exceeds BKT's&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The ADR for this transition is on the backlog.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The student simulator is the missing test fixture for ITS research.&lt;/strong&gt; Standard software testing assumes you can construct any input you need. In adaptive tutoring, your input is a real child's learning trajectory. The simulator bridges that gap — it's not a replacement for real data, but it lets you test that the model responds in the right &lt;em&gt;direction&lt;/em&gt; before you commit to an ethical review and a cohort of participants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BKT and DKT coexist cleanly at the domain layer.&lt;/strong&gt; &lt;code&gt;KCState&lt;/code&gt; stays unchanged. &lt;code&gt;DKTState&lt;/code&gt; is a separate dataclass with a different shape. The backend currently uses &lt;code&gt;KCState&lt;/code&gt;; swapping in &lt;code&gt;DKTState&lt;/code&gt; is an interface change at &lt;code&gt;SubmitAttemptUseCase&lt;/code&gt; and &lt;code&gt;GetNextProblemUseCase&lt;/code&gt; — two files, no schema migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gradient clipping mattered more than I expected.&lt;/strong&gt; Early training runs without &lt;code&gt;clip_grad_norm_&lt;/code&gt; diverged on the frustration-loop persona (all-incorrect sequences). Clipping at &lt;code&gt;max_norm=1.0&lt;/code&gt; stabilised training across all five personas.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Backend wiring: load the trained DKT model at startup, store hidden state vectors in Redis per student, and swap the two use cases. That's the integration step that puts DKT into the live adaptive loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;DKT's shared LSTM hidden state captures cross-KC dependencies that BKT's independent scalar parameters cannot — a student with DIGIT_REVERSAL on subtraction is more likely to have it on place value, and DKT learns this from data&lt;/li&gt;
&lt;li&gt;Build the student simulator before the model: testing an adaptive learning architecture requires synthetic student trajectories, and the simulator lets you validate directional correctness before any ethics review or pilot recruitment&lt;/li&gt;
&lt;li&gt;LSTM beats transformer for short sequences (10–30 steps): attention needs length to work; LSTMs handle variable-length sequences natively and train faster on the small datasets typical of ITS research&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>numpath</category>
      <category>adaptivelearning</category>
      <category>pytorch</category>
      <category>python</category>
    </item>
    <item>
      <title>Building a mistake taxonomy for dyscalculia — 8 error patterns, rule-based, no ML required</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:14:58 +0000</pubDate>
      <link>https://dev.to/orieken/building-a-mistake-taxonomy-for-dyscalculia-8-error-patterns-rule-based-no-ml-required-3707</link>
      <guid>https://dev.to/orieken/building-a-mistake-taxonomy-for-dyscalculia-8-error-patterns-rule-based-no-ml-required-3707</guid>
      <description>&lt;p&gt;"Wrong" isn't a diagnosis.&lt;/p&gt;

&lt;p&gt;When a student answers 32 − 9 = 37, they didn't randomly guess. They subtracted in the wrong direction in the ones column — a specific, named error called a borrow-skip. A tutor that just marks it incorrect and moves on has wasted the most informative signal in the attempt: &lt;em&gt;why&lt;/em&gt; the student got it wrong.&lt;/p&gt;

&lt;p&gt;NumPath's Phase 2 mistake classifier turns wrong answers into structured &lt;code&gt;MistakeEvent&lt;/code&gt; records. Here's how we built it, what we got wrong the first time, and why rule-based classifiers beat a neural network for this job at this stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;Eight rule-based classifiers covering all three of NumPath's Phase 1 skill areas:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Skill&lt;/th&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DIGIT_REVERSAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SUB_BORROW / NUMBER_LINE&lt;/td&gt;
&lt;td&gt;2-digit answer with digits transposed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WRONG_OPERATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SUB_BORROW&lt;/td&gt;
&lt;td&gt;Student added instead of subtracted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BORROW_SKIP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SUB_BORROW&lt;/td&gt;
&lt;td&gt;Ones subtracted in reverse — no borrow taken&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OFF_BY_TEN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SUB_BORROW&lt;/td&gt;
&lt;td&gt;Result ±10 from correct (borrow applied to wrong column)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PLACE_VALUE_CONFUSION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PLACE_VALUE&lt;/td&gt;
&lt;td&gt;Compared units digits only, ignored tens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MAGNITUDE_MISJUDGE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PLACE_VALUE&lt;/td&gt;
&lt;td&gt;Chose the smaller number as larger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NUMBER_LINE_DIRECTION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NUMBER_LINE&lt;/td&gt;
&lt;td&gt;Said "left" when answer is "right"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OFF_BY_ONE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NUMBER_LINE&lt;/td&gt;
&lt;td&gt;Numeric answer ±1 from correct (miscounted steps)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each classifier is a pure Python predicate — no external dependencies, no DB imports, testable in isolation. The main function runs them in priority order and returns the first match.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Design Decision
&lt;/h2&gt;

&lt;p&gt;The first question was: classify with rules or train a model?&lt;/p&gt;

&lt;p&gt;The case for rules: we don't have labelled training data yet. Phase 1 just shipped. We have zero &lt;code&gt;MistakeEvent&lt;/code&gt; records. Training a classifier on nothing produces nothing.&lt;/p&gt;

&lt;p&gt;The case for ML: rules are brittle. A student might make a novel error we didn't anticipate, and rule-based code silently returns &lt;code&gt;None&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We went with rules for Phase 2 because the error patterns for dyscalculia are well-documented in the ITS literature — specifically in the work of VanLehn (1982) on subtraction bugs and the later SIERRA system. "Borrow-skip" and "digit reversal" aren't our taxonomy; they're 40-year-old findings from cognitive science. A rule that detects them is more reliable than a model trained on 150 attempts.&lt;/p&gt;

&lt;p&gt;The ML path opens in Phase 3 once the &lt;code&gt;mistake_events&lt;/code&gt; table has enough volume. The rule-based classifier generates the labelled training data that Phase 3 will learn from.&lt;/p&gt;

&lt;h2&gt;
  
  
  The BORROW_SKIP bug
&lt;/h2&gt;

&lt;p&gt;The Phase 1 classifier had a &lt;code&gt;BORROW_SKIP&lt;/code&gt; function. It was wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Phase 1 — incorrect
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_borrow_skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;problem_content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;operands&lt;/span&gt;
    &lt;span class="n"&gt;no_borrow_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# ← adds a + b, not the borrow-skip result
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;no_borrow_result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This detected addition (32 − 9 → 41) and called it &lt;code&gt;BORROW_SKIP&lt;/code&gt;. But addition is a completely different error — confusing +/− signs, not misapplying the borrowing algorithm. The mistake was labelled wrong in every event record.&lt;/p&gt;

&lt;p&gt;The real borrow-skip pattern: when ones(a) &amp;lt; ones(b), the student skips borrowing and instead subtracts in the wrong direction in the ones column.&lt;/p&gt;

&lt;p&gt;For 32 − 9:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Correct: borrow a ten → 12 − 9 = 3 ones, 2 tens → &lt;strong&gt;23&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Borrow-skip: ones = 9 − 2 = 7, tens = 3 (unchanged) → &lt;strong&gt;37&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Phase 2 — correct
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_borrow_skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ones_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ones_b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ones_a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;ones_b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;  &lt;span class="c1"&gt;# no borrow needed — pattern doesn't apply
&lt;/span&gt;    &lt;span class="n"&gt;borrow_skip_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ones_b&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ones_a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;given&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;borrow_skip_result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified: 32 − 9 → 37 ✓, 43 − 18 → 35 ✓, 31 − 14 → 23 ✓&lt;/p&gt;

&lt;p&gt;The old code was shipping the wrong signal for every borrow-skip attempt. This is exactly why &lt;code&gt;MistakeEvent&lt;/code&gt; records are useless until the classifier is correct — the adaptive engine was routing "borrow-skip" students to the wrong remediation path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The priority ordering problem
&lt;/h2&gt;

&lt;p&gt;Multiple patterns can fire for the same wrong answer. For 43 − 16 = 27, the student wrote 72. That's a &lt;code&gt;DIGIT_REVERSAL&lt;/code&gt; (27 reversed). But priority ordering becomes meaningful when patterns genuinely overlap.&lt;/p&gt;

&lt;p&gt;The classifier runs a hierarchy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;subtraction problems:
  1. DIGIT_REVERSAL    ← most specific free-form error
  2. WRONG_OPERATION   ← added instead of subtracted
  3. BORROW_SKIP       ← skipped borrowing algorithm
  4. OFF_BY_TEN        ← borrow applied to wrong column

place_value problems (multiple-choice — no free-form digit writing):
  1. PLACE_VALUE_CONFUSION  ← compared units digits only (more specific)
  2. MAGNITUDE_MISJUDGE     ← picked the smaller number (less specific)

number_line problems:
  1. NUMBER_LINE_DIRECTION  ← wrong direction word
  2. DIGIT_REVERSAL         ← transposed digits in numeric answer
  3. OFF_BY_ONE             ← miscounted steps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Place value problems are multiple-choice, so &lt;code&gt;DIGIT_REVERSAL&lt;/code&gt; doesn't apply there — the student picks from a given set, they don't write digits freely. Scoping by problem type prevents false positives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Matters for the Research
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;MistakeEvent&lt;/code&gt; record becomes a training signal twice:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now&lt;/strong&gt;: the adaptive engine reads the last &lt;code&gt;MISTAKE_WINDOW&lt;/code&gt; (3) events. Two &lt;code&gt;BORROW_SKIP&lt;/code&gt; codes in a row triggers remediation mode — the engine drops difficulty and targets &lt;code&gt;SUB_BORROW&lt;/code&gt; problems specifically. Correct classification = correct remediation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Later&lt;/strong&gt;: Phase 3 will train a logistic regression (and eventually a transformer) on the mistake events table. The rule-based classifier generates the initial labelled dataset. If the rules are wrong — as &lt;code&gt;BORROW_SKIP&lt;/code&gt; was — the ML model learns the wrong pattern from poisoned labels.&lt;/p&gt;

&lt;p&gt;For a dyscalculia intervention study, this matters more than it would in a general tutoring system. Dyscalculia-specific errors like borrow-skip and digit reversal appear in the ITS literature as distinct cognitive profiles. Getting them right means the model can eventually distinguish students who have a procedural gap (&lt;code&gt;BORROW_SKIP&lt;/code&gt;) from students who have a representational gap (&lt;code&gt;PLACE_VALUE_CONFUSION&lt;/code&gt;) — a distinction that should affect the instructional intervention.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rule-based classifiers need domain literature, not just intuition.&lt;/strong&gt; The original &lt;code&gt;BORROW_SKIP&lt;/code&gt; implementation was plausible — "student added instead of subtracting" — but wrong. VanLehn's subtraction bug taxonomy makes the actual pattern explicit. Reading the paper would have saved months of mislabelled data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Priority ordering is a design document.&lt;/strong&gt; The order in which classifiers run encodes assumptions about what matters more. We chose "most specific fires first" — but that could be wrong. Maybe &lt;code&gt;WRONG_OPERATION&lt;/code&gt; (a conceptual error) should always beat &lt;code&gt;DIGIT_REVERSAL&lt;/code&gt; (a transcription error) regardless of specificity, because they imply different interventions. We don't have the data to answer that yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;50 tests is the right investment for a classifier that labels training data.&lt;/strong&gt; A wrong label propagates forward through every model that trains on it. Testing every predicate in isolation, including priority ordering and edge cases, is not over-engineering — it's protecting the integrity of the entire data pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;mistake_events&lt;/code&gt; table is now correctly populated with each session. Once the pilot delivers ≥150 records, Phase 3 can fit a logistic regression on the labelled events — using the rule-based codes as ground truth — and eventually replace the rules with a model that generalises to error patterns we haven't seen yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Rule-based mistake classifiers are the right first step when training data doesn't exist yet — they generate the labelled dataset that trains the eventual ML model&lt;/li&gt;
&lt;li&gt;The real borrow-skip pattern (subtract ones in reverse: 32−9=37) is different from wrong-operation (add instead of subtract: 32+9=41) — getting this wrong poisons every downstream model that trains on the events table&lt;/li&gt;
&lt;li&gt;Classifier priority ordering is a design decision that encodes instructional theory; document it explicitly and treat it as something to validate with data&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>numpath</category>
      <category>dyscalculia</category>
      <category>adaptivelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>Building a FastAPI + Vue 3 research platform: the 4 bugs that almost broke Phase 1</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Mon, 01 Jun 2026 02:11:33 +0000</pubDate>
      <link>https://dev.to/orieken/building-a-fastapi-vue-3-research-platform-the-4-bugs-that-almost-broke-phase-1-10de</link>
      <guid>https://dev.to/orieken/building-a-fastapi-vue-3-research-platform-the-4-bugs-that-almost-broke-phase-1-10de</guid>
      <description>&lt;p&gt;Phase 1 of NumPath is done. Seven of eight Definition of Done items are checked — the eighth requires real children completing pilot sessions, which no amount of code will substitute for. The stack runs cleanly in Docker Compose, 56 unit tests pass, and a student can log in, answer ten problems, and see their knowledge state update in real time.&lt;/p&gt;

&lt;p&gt;What the commit history doesn't show is the afternoon I spent fighting four bugs that don't appear in any FastAPI or Vue tutorial. This post is that afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;NumPath is an adaptive math tutor for children with dyscalculia. Phase 1 ships the minimum research instrument: a student practice loop, a rule-based adaptive engine, and a read-only teacher dashboard. No ML yet — just clean infrastructure and a data collection pipeline capable of generating the 150+ attempt records that Phase 2 needs to train the BKT model.&lt;/p&gt;

&lt;p&gt;The stack: FastAPI 0.110 + SQLAlchemy 2 + Alembic + asyncpg on the backend; Vue 3 + Tailwind + Pinia on the frontend; PostgreSQL 16 + Redis 7 in Docker Compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: passlib AttributeError on bcrypt ≥4.0
&lt;/h2&gt;

&lt;p&gt;The symptom was immediate on first login attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AttributeError: module 'bcrypt' has no attribute '__about__'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;passlib&lt;/code&gt; has a version check that reads &lt;code&gt;bcrypt.__about__.__version__&lt;/code&gt;. bcrypt 4.0 removed the &lt;code&gt;__about__&lt;/code&gt; module. The libraries have been incompatible for two years and &lt;code&gt;passlib&lt;/code&gt; is effectively unmaintained.&lt;/p&gt;

&lt;p&gt;The fix: delete &lt;code&gt;passlib&lt;/code&gt; entirely. Replace it with three lines of direct &lt;code&gt;bcrypt&lt;/code&gt; calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend/auth/password.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hash_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hashpw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gensalt&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkpw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;hashed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pyproject.toml&lt;/code&gt;: swap &lt;code&gt;"passlib[bcrypt]&amp;gt;=1.7.4"&lt;/code&gt; for &lt;code&gt;"bcrypt&amp;gt;=4.0.0"&lt;/code&gt;. Done. Don't reach for passlib on new Python projects — the dependency is dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: pnpm 10 security policies blocking Docker builds
&lt;/h2&gt;

&lt;p&gt;The frontend Dockerfile used &lt;code&gt;node:20-slim&lt;/code&gt; and installed the latest pnpm via corepack. When pnpm 10 shipped, the build started failing with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERR_PNPM_PREPARE_PKG_FAILURE  Error when preparing the package
 Blocked by policy: electron-to-chromium@1.5.134 is not allowed
 because it was released 0 days ago (policy: minimumReleaseAge=3 days)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pnpm 10 introduced release-age security policies that refuse to install packages published within the last N days. A reasonable feature in production — a CI-breaking surprise when your lock file pins a package that was published yesterday.&lt;/p&gt;

&lt;p&gt;Two separate policies hit us: &lt;code&gt;minimumReleaseAge&lt;/code&gt; and &lt;code&gt;ignored-builds&lt;/code&gt; (which blocks &lt;code&gt;esbuild&lt;/code&gt; and &lt;code&gt;vue-demi&lt;/code&gt; unless explicitly allowed). The &lt;code&gt;package.json&lt;/code&gt; &lt;code&gt;"pnpm"&lt;/code&gt; field that's supposed to configure these policies is silently ignored in pnpm 10 — it logs a warning and reads nothing.&lt;/p&gt;

&lt;p&gt;The fix: pin to pnpm 9:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;corepack &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; corepack prepare pnpm@9.15.9 &lt;span class="nt"&gt;--activate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pnpm 9 has no release-age policies. The upgrade to pnpm 10 can wait until the project has a proper CI environment to absorb the breaking change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: FastAPI container connecting to localhost instead of postgres
&lt;/h2&gt;

&lt;p&gt;The backend started cleanly. Every database call returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;asyncpg.exceptions.ConnectionRefusedError: connection refused (host 127.0.0.1, port 5432)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;DATABASE_URL&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt; was &lt;code&gt;postgresql+asyncpg://numpath:numpath@localhost:5432/numpath&lt;/code&gt;. Inside a Docker Compose network, &lt;code&gt;localhost&lt;/code&gt; is the container's own loopback — not the postgres service. The postgres container is reachable by its service name.&lt;/p&gt;

&lt;p&gt;The fix: override the env var at the service level in &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;../.env&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgresql+asyncpg://numpath:numpath@postgres:5432/numpath&lt;/span&gt;
    &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://redis:6379/0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;environment&lt;/code&gt; block wins over &lt;code&gt;env_file&lt;/code&gt;, so local development (which uses &lt;code&gt;localhost&lt;/code&gt;) keeps working. Containers talk to each other by service name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: SQLAlchemy column defaults not applied at construction time
&lt;/h2&gt;

&lt;p&gt;This one cost the most time. &lt;code&gt;POST /attempts&lt;/code&gt; returned a 500:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: unsupported operand type(s) for -: 'int' and 'NoneType'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The BKT update equation was subtracting from &lt;code&gt;p_learn&lt;/code&gt;, which was &lt;code&gt;None&lt;/code&gt;. The &lt;code&gt;KCStateRecord&lt;/code&gt; model had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;KCStateRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;p_learn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p_guess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p_slip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug: SQLAlchemy's &lt;code&gt;default=&lt;/code&gt; is a &lt;strong&gt;server-side or flush-time default&lt;/strong&gt;. When you construct &lt;code&gt;KCStateRecord()&lt;/code&gt; in Python and haven't flushed to the database yet, those columns are &lt;code&gt;None&lt;/code&gt; on the Python object. The domain code ran immediately after construction, before any flush.&lt;/p&gt;

&lt;p&gt;The fix: set defaults explicitly in the constructor, then flush and refresh before returning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;KCStateRecord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;student_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;skill_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;skill_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;p_mastery&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;p_learn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;p_guess&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;p_slip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;opportunity_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;         &lt;span class="c1"&gt;# write to DB so defaults are applied
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# re-read the DB-populated values
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule: if you use a newly constructed SQLAlchemy model object before any flush, assume every &lt;code&gt;default=&lt;/code&gt; column is &lt;code&gt;None&lt;/code&gt;. Either set defaults in the constructor or flush first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the BKT update looks like in practice
&lt;/h2&gt;

&lt;p&gt;With those bugs cleared, the full attempt flow works end to end. A correct answer on a &lt;code&gt;SUB_BORROW&lt;/code&gt; problem with a fresh KCState shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;before: p_mastery=0.100, opportunity_count=0
after:  p_mastery=0.533, opportunity_count=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That 0.1 → 0.533 jump is the Bayesian update working — prior &lt;code&gt;p_mastery&lt;/code&gt; combines with &lt;code&gt;p_learn&lt;/code&gt;, corrected for &lt;code&gt;p_guess&lt;/code&gt; and &lt;code&gt;p_slip&lt;/code&gt;. The math is covered in detail in &lt;a href="https://dev.to/orieken/bayesian-knowledge-tracing-in-37-lines-of-python"&gt;Bayesian Knowledge Tracing in 37 lines of Python&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Matters for the Research
&lt;/h2&gt;

&lt;p&gt;Phase 1's job was never to be elegant — it was to be &lt;em&gt;instrumented&lt;/em&gt;. Every attempt record written to the &lt;code&gt;attempts&lt;/code&gt; table is a training signal for Phase 2's BKT parameter estimation. We need ≥150 records (5 students × 3 sessions × 10+ problems) before Phase 2 can begin.&lt;/p&gt;

&lt;p&gt;The bugs above are why research-grade software is harder than it looks. Each one silently corrupts data in a different way: password hashing fails outright (detectable), Docker networking fails silently on every write (detectable but subtle), SQLAlchemy defaults produce &lt;code&gt;None&lt;/code&gt; BKT parameters (corrupts ML inputs, hard to detect in test data).&lt;/p&gt;

&lt;p&gt;The fix for all of them is the same: run the full stack. Not unit tests. Not &lt;code&gt;import my_function; print(my_function())&lt;/code&gt;. Start the containers, log in as a real user, and watch what happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;The honest retrospective:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seed data is harder than it looks.&lt;/strong&gt; Writing 60 hand-crafted math problems at three difficulty levels takes longer than writing the adaptive engine. Every problem needs a machine-checkable answer, a hint, and a calibrated difficulty score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker Compose &lt;code&gt;env_file&lt;/code&gt; + &lt;code&gt;environment&lt;/code&gt; is the right pattern.&lt;/strong&gt; &lt;code&gt;env_file&lt;/code&gt; carries the defaults; &lt;code&gt;environment&lt;/code&gt; carries container-specific overrides. The pattern is obvious in hindsight and invisible until you need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;flush()&lt;/code&gt; + &lt;code&gt;refresh()&lt;/code&gt; pattern is load-bearing for async SQLAlchemy.&lt;/strong&gt; Any code that creates an ORM object and immediately passes it to domain logic needs an explicit flush. The async path doesn't auto-flush the way the synchronous ORM used to.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Phase 2: BKT parameter estimation from real student data, and a mistake classifier that categorises subtraction errors beyond "wrong." The &lt;code&gt;attempts&lt;/code&gt; table is waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;passlib&lt;/code&gt; is dead — use &lt;code&gt;bcrypt&lt;/code&gt; directly; it's three functions and no transitive dependency risk&lt;/li&gt;
&lt;li&gt;Docker Compose containers reach each other by service name, not &lt;code&gt;localhost&lt;/code&gt;; override &lt;code&gt;DATABASE_URL&lt;/code&gt; in the &lt;code&gt;environment&lt;/code&gt; block rather than the &lt;code&gt;env_file&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;SQLAlchemy &lt;code&gt;default=&lt;/code&gt; columns are &lt;code&gt;None&lt;/code&gt; on a freshly constructed Python object until after a &lt;code&gt;flush()&lt;/code&gt; + &lt;code&gt;refresh()&lt;/code&gt; — always set constructor defaults explicitly when domain code runs immediately after creation&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>numpath</category>
      <category>fastapi</category>
      <category>docker</category>
      <category>python</category>
    </item>
    <item>
      <title>Bayesian Knowledge Tracing in 37 lines of Python — how NumPath models what a student knows</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Wed, 27 May 2026 05:23:15 +0000</pubDate>
      <link>https://dev.to/orieken/bayesian-knowledge-tracing-in-37-lines-of-python-how-numpath-models-what-a-student-knows-1if8</link>
      <guid>https://dev.to/orieken/bayesian-knowledge-tracing-in-37-lines-of-python-how-numpath-models-what-a-student-knows-1if8</guid>
      <description>&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;NumPath maintains a &lt;code&gt;KCState&lt;/code&gt; for every student × Knowledge Component pair. After every attempt, &lt;code&gt;update_bkt()&lt;/code&gt; revises the probability that the student has mastered that KC. That probability — &lt;code&gt;p_mastery&lt;/code&gt; — is what the adaptive engine reads to pick the next problem and what the teacher dashboard displays as a progress bar.&lt;/p&gt;

&lt;p&gt;The entire model is 37 lines. Here it is unabridged.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="n"&gt;MASTERY_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;KCState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;p_mastery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;p_learn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;p_guess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;p_slip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;opportunity_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_mastered&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;p_mastery&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;MASTERY_THRESHOLD&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update_bkt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;KCState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_correct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;KCState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Standard Bayesian Knowledge Tracing update (Corbett &amp;amp; Anderson, 1995).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;p_mastery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;p_learn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;p_guess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;p_slip&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_correct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;posterior&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;posterior&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;S&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;p_new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;posterior&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;posterior&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;L&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;KCState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;p_mastery&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_new&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="n"&gt;p_learn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p_guess&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;p_slip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;opportunity_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;opportunity_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Four Parameters
&lt;/h2&gt;

&lt;p&gt;BKT models each KC with four parameters, all probabilities between 0 and 1:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;NumPath default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p_mastery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;P(student has learned this KC)&lt;/td&gt;
&lt;td&gt;0.10 (prior — low, conservative)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p_learn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;P(learning occurs on this attempt, given not yet learned)&lt;/td&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p_guess&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;P(correct answer given KC &lt;strong&gt;not&lt;/strong&gt; learned)&lt;/td&gt;
&lt;td&gt;0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p_slip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;P(incorrect answer given KC &lt;strong&gt;is&lt;/strong&gt; learned)&lt;/td&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These are Phase 1 seed values — not calibrated against real student data yet. The parameter estimation problem (fitting &lt;code&gt;p_learn&lt;/code&gt;, &lt;code&gt;p_guess&lt;/code&gt;, &lt;code&gt;p_slip&lt;/code&gt; per KC from observed attempts) is a Phase 4 task once the RCT produces enough data. For now they are reasonable priors from the BKT literature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Update Equations
&lt;/h2&gt;

&lt;p&gt;After observing an answer, two steps happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Bayesian update&lt;/strong&gt; (prior → posterior):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Correct:   posterior = p(1 - S) / [p(1 - S) + (1 - p)G]
Incorrect: posterior = pS       / [pS       + (1 - p)(1 - G)]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is straight Bayes. A correct answer raises the posterior &lt;em&gt;unless&lt;/em&gt; the student is likely to have guessed. An incorrect answer lowers it &lt;em&gt;unless&lt;/em&gt; the student is likely to have slipped. A correct answer from a student with &lt;code&gt;p_mastery=0.95&lt;/code&gt; and &lt;code&gt;p_slip=0.10&lt;/code&gt; barely moves the needle — the model already thinks they know it. A correct answer from a student with &lt;code&gt;p_mastery=0.10&lt;/code&gt; and &lt;code&gt;p_guess=0.20&lt;/code&gt; moves it less than you might expect — the model discounts lucky guesses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — Learning update&lt;/strong&gt; (posterior → next prior):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p_new = posterior + (1 - posterior) × p_learn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even if the student answered incorrectly, there's a &lt;code&gt;p_learn&lt;/code&gt; probability that learning occurred anyway. The posterior is never the final state — the learning update always nudges &lt;code&gt;p_mastery&lt;/code&gt; upward slightly, reflecting that every attempt is an opportunity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Design Decision
&lt;/h2&gt;

&lt;p&gt;We evaluated three approaches before choosing standard BKT:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Item Response Theory (IRT)&lt;/strong&gt; — models item difficulty as well as student ability. More expressive, but requires calibrated item parameters we don't have. Rejected for Phase 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deep Knowledge Tracing (DKT)&lt;/strong&gt; — replaces the parametric model with an LSTM that learns latent student state from sequences of attempts. Better at capturing cross-KC transfer. Rejected for Phase 1 because it needs training data we haven't collected yet. It's on the Phase 2 roadmap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Accuracy streak&lt;/strong&gt; — raise difficulty after 3 correct in a row, lower after 3 wrong. This is what most commercial apps do. Rejected because it gives you no probability estimate, no per-KC granularity, and no way to distinguish a guesser from a learner.&lt;/p&gt;

&lt;p&gt;Standard BKT is 30 years old and still the right choice when you're instrument-building before data collection. It gives you a per-KC probability estimate with interpretable parameters, it's fast to compute, and its failure modes are well understood.&lt;/p&gt;

&lt;p&gt;One implementation choice worth noting: &lt;code&gt;KCState&lt;/code&gt; is a frozen dataclass. &lt;code&gt;update_bkt()&lt;/code&gt; returns a new &lt;code&gt;KCState&lt;/code&gt; rather than mutating the existing one. This makes the update function a pure function — easy to test, easy to replay, and safe to call in parallel if we ever need to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Matters for the Research
&lt;/h2&gt;

&lt;p&gt;The RCT compares learning outcomes for students using NumPath against a control group using static worksheets. To measure a difference, you need a measurement instrument. &lt;code&gt;p_mastery&lt;/code&gt; is that instrument.&lt;/p&gt;

&lt;p&gt;After a session, the teacher dashboard shows each student's &lt;code&gt;p_mastery&lt;/code&gt; per KC as a progress bar. The adaptive engine uses it to pick the next problem. The LLM insight generator reads it to produce explanations like &lt;em&gt;"Aiden's &lt;code&gt;p_mastery&lt;/code&gt; on &lt;code&gt;SUB_BORROW&lt;/code&gt; is 0.18 — the model has seen 11 attempts and is not converging."&lt;/em&gt; All three downstream consumers depend on the same number being meaningful.&lt;/p&gt;

&lt;p&gt;BKT's key property for research purposes: it's falsifiable. If a student's &lt;code&gt;p_mastery&lt;/code&gt; stays low after 20 correct answers, that's a signal worth investigating — either the parameters are wrong, or the student is consistently guessing, or there's a measurement problem. An accuracy percentage doesn't give you that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;The model is simple. Getting the parameters right is not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;p_learn=0.30&lt;/code&gt; means the student has a 30% chance of learning the KC on any given attempt. That sounds reasonable. But it implies that after 10 attempts, a student who has not yet learned the KC has a 97% cumulative chance of learning it — which is almost certainly too optimistic. The seed parameters will need calibration.&lt;/p&gt;

&lt;p&gt;The other thing we learned: &lt;code&gt;opportunity_count&lt;/code&gt; is load-bearing. The adaptive engine uses it as a tiebreaker and the teacher dashboard shows it alongside &lt;code&gt;p_mastery&lt;/code&gt;. It's not computed from the BKT model — it's just a counter that increments on every &lt;code&gt;update_bkt()&lt;/code&gt; call. The frozen dataclass pattern makes this safe: the count in the database is always the count from the last &lt;code&gt;update_bkt()&lt;/code&gt; return value, never a stale mutation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Phase 2 adds a DKT model alongside BKT — trained on the data collected during the pilot. The two models will run in parallel so we can compare their predictions against observed outcomes before the RCT begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BKT separates learning from performance&lt;/strong&gt; — &lt;code&gt;p_guess&lt;/code&gt; and &lt;code&gt;p_slip&lt;/code&gt; let the model discount lucky correct answers and unlucky wrong ones; a 70% accuracy rate means something different depending on what the model thinks caused it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;p_mastery&lt;/code&gt; is the measurement instrument for the RCT&lt;/strong&gt; — every downstream consumer (adaptive engine, teacher dashboard, LLM insights) reads the same number, so getting it right matters more than getting it fast&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frozen dataclass + pure function = safe update chain&lt;/strong&gt; — &lt;code&gt;update_bkt()&lt;/code&gt; returns a new &lt;code&gt;KCState&lt;/code&gt;; there's no shared mutable state, the update is replayable, and the test suite can verify every case in isolation&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>numpath</category>
      <category>adaptivelearning</category>
      <category>bayesian</category>
      <category>python</category>
    </item>
    <item>
      <title>Two Cross-Platform Bugs in Our Go CLI (And How We Fixed Them)</title>
      <dc:creator>Oscar Rieken</dc:creator>
      <pubDate>Wed, 27 May 2026 05:22:56 +0000</pubDate>
      <link>https://dev.to/orieken/two-cross-platform-bugs-in-our-go-cli-and-how-we-fixed-them-4h6l</link>
      <guid>https://dev.to/orieken/two-cross-platform-bugs-in-our-go-cli-and-how-we-fixed-them-4h6l</guid>
      <description>&lt;p&gt;Go's cross-platform story is genuinely good. Write code once, compile for any target, mostly just works. But "mostly" hides a couple of sharp edges that bit us while building TestSmith. Both bugs were invisible on macOS and Linux, only surfaced on Windows CI, and had the same root cause: assumptions about path separators and filesystem traversal boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: The Detector Boundary Escape
&lt;/h2&gt;

&lt;p&gt;TestSmith has five language drivers, each responsible for detecting whether a directory is a project of its type. The Python driver walks upward from the starting directory, looking for &lt;code&gt;pyproject.toml&lt;/code&gt; or &lt;code&gt;setup.py&lt;/code&gt;. The Go driver looks for &lt;code&gt;go.mod&lt;/code&gt;. And so on.&lt;/p&gt;

&lt;p&gt;The bug: every driver would happily walk past a &lt;code&gt;.git&lt;/code&gt; directory belonging to a &lt;em&gt;different&lt;/em&gt; project and claim files in an ancestor project.&lt;/p&gt;

&lt;p&gt;Here's what happened in practice. Our example projects live at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;testsmith/                  ← Go repo root (.git here)
  examples/
    python-service/          ← Python example project
      pyproject.toml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you ran &lt;code&gt;testsmith generate&lt;/code&gt; from inside &lt;code&gt;examples/python-service/&lt;/code&gt;, the Python driver would detect it correctly. But when you ran it from &lt;code&gt;examples/go-service/&lt;/code&gt; and the Python driver was tried first during registry detection, it would walk upward, find no Python markers in &lt;code&gt;go-service/&lt;/code&gt;, then continue upward, find no markers in &lt;code&gt;examples/&lt;/code&gt;, then continue upward... find &lt;code&gt;conftest.py&lt;/code&gt; at the testsmith repo root (left over from a previous test run), and claim the entire testsmith repo as a Python project.&lt;/p&gt;

&lt;p&gt;The naive fix is "stop when you see &lt;code&gt;.git&lt;/code&gt;." But that's wrong too — a legitimate project root can have both &lt;code&gt;pyproject.toml&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; a &lt;code&gt;.git&lt;/code&gt; directory. If you stop at the first &lt;code&gt;.git&lt;/code&gt; you see, you'd refuse to detect projects that are also VCS roots.&lt;/p&gt;

&lt;p&gt;The correct rule: check VCS stop markers only at &lt;strong&gt;ancestor&lt;/strong&gt; directories, not at the starting directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;findRoot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;startDir&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;startDir&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// Only check VCS boundaries at ancestor dirs — the starting dir&lt;/span&gt;
        &lt;span class="c"&gt;// may legitimately have both a project marker and a .git directory.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;startDir&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;stopMarkers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrProjectNotFound&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;rootMarkers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Stat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parent&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrProjectNotFound&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We applied this pattern to all five drivers. The key insight: &lt;code&gt;.git&lt;/code&gt; is a traversal-stopping sentinel when found in an &lt;em&gt;ancestor&lt;/em&gt;, but it's perfectly normal at the &lt;em&gt;project root itself&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: Hardcoded Path Separators
&lt;/h2&gt;

&lt;p&gt;The Windows test failure was more direct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;    analyzer_test.go:179: DeriveTestPath("/proj/src/services/payment.py"):
        got "\\proj\\tests\\src\\services\\test_payment.py",
        want "/proj/tests/services/test_payment.py"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things wrong in that output: backslashes (expected on Windows, handled by the test via &lt;code&gt;filepath.ToSlash&lt;/code&gt;), and &lt;code&gt;src&lt;/code&gt; appearing in the output path when it should have been stripped.&lt;/p&gt;

&lt;p&gt;The code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;deriveTestPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sourcePath&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProjectContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sourcePath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// Strip src/ prefix if present.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"src/"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;  &lt;span class="c"&gt;// ← BUG&lt;/span&gt;
        &lt;span class="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;filepath.Rel&lt;/code&gt; on Windows returns &lt;code&gt;src\services\payment.py&lt;/code&gt;. The prefix check looks for &lt;code&gt;src/&lt;/code&gt; with a forward slash. On Windows, it never matches. The &lt;code&gt;src&lt;/code&gt; component stays in the path, so the output becomes &lt;code&gt;tests\src\services\test_payment.py&lt;/code&gt; instead of &lt;code&gt;tests\services\test_payment.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix normalises to forward slashes before the check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Normalise to forward slashes for the prefix check so this works on&lt;/span&gt;
&lt;span class="c"&gt;// Windows (where filepath.Rel returns backslash-separated paths).&lt;/span&gt;
&lt;span class="n"&gt;slashed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToSlash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasPrefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slashed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"src/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;slashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;slashed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FromSlash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slashed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;filepath.ToSlash&lt;/code&gt; converts &lt;code&gt;\&lt;/code&gt; to &lt;code&gt;/&lt;/code&gt;. &lt;code&gt;strings.HasPrefix(slashed, "src/")&lt;/code&gt; works correctly on all platforms. &lt;code&gt;filepath.FromSlash&lt;/code&gt; converts back to the OS-native separator for the subsequent &lt;code&gt;filepath.Join&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;The same pattern applied to &lt;code&gt;deriveModulePath&lt;/code&gt;, which had the identical bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pattern
&lt;/h2&gt;

&lt;p&gt;Both bugs share a structure: an algorithm that works correctly on the development platform (macOS/Linux) but silently produces wrong results on Windows because it makes assumptions about the filesystem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bug 1&lt;/strong&gt;: assumes &lt;code&gt;.git&lt;/code&gt; presence implies "not a project root" (wrong at the starting dir)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bug 2&lt;/strong&gt;: assumes &lt;code&gt;filepath.Rel&lt;/code&gt; uses forward slashes (wrong on Windows)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The remedies are similarly structured:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bug 1&lt;/strong&gt;: be explicit about &lt;em&gt;which&lt;/em&gt; directories the invariant applies to&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bug 2&lt;/strong&gt;: normalise to a known format before string operations, then convert back for OS operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Go's &lt;code&gt;filepath&lt;/code&gt; package is excellent — &lt;code&gt;filepath.Rel&lt;/code&gt;, &lt;code&gt;filepath.Join&lt;/code&gt;, &lt;code&gt;filepath.Dir&lt;/code&gt;, &lt;code&gt;filepath.Base&lt;/code&gt; all do the right thing. The problems arise when you mix &lt;code&gt;filepath&lt;/code&gt; results with hardcoded string literals (like &lt;code&gt;"src/"&lt;/code&gt;) that embed platform assumptions. The rule: use &lt;code&gt;filepath&lt;/code&gt; functions for path &lt;em&gt;operations&lt;/em&gt;, &lt;code&gt;filepath.ToSlash&lt;/code&gt; to convert &lt;em&gt;before&lt;/em&gt; any string matching, and &lt;code&gt;filepath.FromSlash&lt;/code&gt; to convert &lt;em&gt;back&lt;/em&gt; before passing to OS calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI as the Detector
&lt;/h2&gt;

&lt;p&gt;Neither bug would have been caught by running tests locally on macOS. The Windows CI job was the only place they surfaced.&lt;/p&gt;

&lt;p&gt;This is the case for a real cross-platform test matrix. It's not just about supporting Windows users — it's about finding any code that makes implicit platform assumptions. If your tests only run on one platform, that class of bug is invisible until a user reports it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;TestSmith is open source at &lt;a href="https://github.com/orieken/testsmith" rel="noopener noreferrer"&gt;github.com/orieken/testsmith&lt;/a&gt;. The full CI matrix runs on Ubuntu, macOS, and Windows with &lt;code&gt;-race&lt;/code&gt; enabled on all three.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>debugging</category>
      <category>windows</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
