learningfoundry 0.74.1__tar.gz → 0.79.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/CHANGELOG.md +166 -0
  2. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/PKG-INFO +51 -8
  3. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/README.md +50 -7
  4. learningfoundry-0.79.2/docs/specs/nbfoundry/README.md +100 -0
  5. learningfoundry-0.79.2/docs/specs/pyve/README.md +1213 -0
  6. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/pyproject.toml +1 -1
  7. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/__init__.py +1 -1
  8. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/resolver.py +3 -0
  9. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/schema_v1.py +46 -0
  10. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/package.json +4 -4
  11. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/pnpm-lock.yaml +72 -359
  12. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte +40 -14
  13. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonList.test.ts +195 -34
  14. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.test.ts +13 -1
  15. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte +7 -1
  16. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.test.ts +1 -2
  17. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.test.ts +2 -4
  18. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.test.ts +11 -3
  19. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.test.ts +1 -0
  20. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts +5 -2
  21. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/db/database.test.ts +40 -1
  22. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/db/database.ts +50 -1
  23. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts +207 -5
  24. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/db/progress.ts +122 -5
  25. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.test.ts +43 -8
  26. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts +22 -4
  27. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.test.ts +1 -2
  28. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/types/index.ts +35 -2
  29. learningfoundry-0.79.2/src/learningfoundry/sveltekit_template/src/lib/utils/assessment-passed.test.ts +51 -0
  30. learningfoundry-0.79.2/src/learningfoundry/sveltekit_template/src/lib/utils/assessment-passed.ts +32 -0
  31. learningfoundry-0.79.2/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +510 -0
  32. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts +112 -11
  33. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.test.ts +1 -2
  34. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/page.test.ts +13 -0
  35. learningfoundry-0.79.2/src/learningfoundry/sveltekit_template/src/routes/[module]/assessment/[id]/+page.svelte +95 -0
  36. learningfoundry-0.79.2/src/learningfoundry/sveltekit_template/src/routes/[module]/assessment/[id]/page.test.ts +237 -0
  37. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/vite.config.ts +20 -2
  38. learningfoundry-0.74.1/src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts +0 -237
  39. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/.gitignore +0 -0
  40. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/LICENSE +0 -0
  41. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/docs/specs/quizazz/README.md +0 -0
  42. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/__main__.py +0 -0
  43. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/asset_resolver.py +0 -0
  44. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/cli.py +0 -0
  45. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/config.py +0 -0
  46. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/directives.py +0 -0
  47. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/exceptions.py +0 -0
  48. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/generator.py +0 -0
  49. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/integrations/__init__.py +0 -0
  50. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/integrations/d3foundry_stub.py +0 -0
  51. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/integrations/nbfoundry_stub.py +0 -0
  52. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/integrations/protocols.py +0 -0
  53. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/integrations/quizazz.py +0 -0
  54. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/logging_config.py +0 -0
  55. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/parser.py +0 -0
  56. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/pipeline.py +0 -0
  57. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/py.typed +0 -0
  58. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/schema_extensions.py +0 -0
  59. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/README.md +0 -0
  60. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/finish.spec.ts +0 -0
  61. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/fixtures/curriculum.json +0 -0
  62. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/global-teardown.ts +0 -0
  63. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/lifecycle.spec.ts +0 -0
  64. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/navigation.spec.ts +0 -0
  65. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/progress.spec.ts +0 -0
  66. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/reset.spec.ts +0 -0
  67. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/text-block-bottom.spec.ts +0 -0
  68. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/e2e/video.spec.ts +0 -0
  69. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/playwright.config.ts +0 -0
  70. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/pnpm-workspace.yaml +0 -0
  71. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/app.css +0 -0
  72. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/app.html +0 -0
  73. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/AssessmentBlock.svelte +0 -0
  74. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/AssessmentBlock.test.ts +0 -0
  75. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/ContentBlock.svelte +0 -0
  76. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/ExerciseBlock.svelte +0 -0
  77. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/LessonView.svelte +0 -0
  78. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/LockedLessonPlaceholder.svelte +0 -0
  79. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/Navigation.svelte +0 -0
  80. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/PlaceholderBlock.svelte +0 -0
  81. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressBar.svelte +0 -0
  82. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/ProgressDashboard.svelte +0 -0
  83. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/RecordingPausedBanner.svelte +0 -0
  84. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/RecordingPausedBanner.test.ts +0 -0
  85. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.svelte +0 -0
  86. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/ResetCourseButton.test.ts +0 -0
  87. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.observer.test.ts +0 -0
  88. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.svelte +0 -0
  89. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/TextBlock.test.ts +0 -0
  90. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/VideoBlock.svelte +0 -0
  91. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/VisualizationBlock.svelte +0 -0
  92. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/lesson-view.helpers.ts +0 -0
  93. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/module-list.helpers.ts +0 -0
  94. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/mount.test.ts +0 -0
  95. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/navigation.test.ts +0 -0
  96. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/components/progress-dashboard.helpers.ts +0 -0
  97. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/db/index.ts +0 -0
  98. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.test.ts +0 -0
  99. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/db/user-id.ts +0 -0
  100. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/stores/db-init.test.ts +0 -0
  101. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/stores/db-init.ts +0 -0
  102. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/stores/progress.ts +0 -0
  103. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/utils/duration.test.ts +0 -0
  104. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/utils/duration.ts +0 -0
  105. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown-directives.ts +0 -0
  106. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.test.ts +0 -0
  107. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/utils/markdown.ts +0 -0
  108. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/utils/progress.ts +0 -0
  109. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/lib/utils/viewport-completion.ts +0 -0
  110. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/routes/+layout.svelte +0 -0
  111. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/routes/+layout.ts +0 -0
  112. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/routes/+page.svelte +0 -0
  113. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/routes/[module]/[lesson]/+page.svelte +0 -0
  114. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/routes/layout.helpers.ts +0 -0
  115. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.test.ts +0 -0
  116. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/routes/layout.scroll.ts +0 -0
  117. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/src/routes/layout.test.ts +0 -0
  118. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/static/.gitkeep +0 -0
  119. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/svelte.config.js +0 -0
  120. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/test-results/.last-run.json +0 -0
  121. {learningfoundry-0.74.1 → learningfoundry-0.79.2}/src/learningfoundry/sveltekit_template/tsconfig.json +0 -0
@@ -7,6 +7,172 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.79.2] - 2026-06-02
11
+
12
+ Fix sql.js browser-ESM init failure in the SvelteKit template's dev-server preview path. Module and lesson routes were returning 500 in `learningfoundry preview` because `(await import('sql.js')).default` was `undefined` in the browser. Diagnosed in `docs/specs/bug-sql-js-browser-esm-spec.md`; fixed via Story J.y.
13
+
14
+ ### Fixed
15
+
16
+ - **`vite.config.ts` — `optimizeDeps.exclude` scoped to test mode only.** The exclude was added by Story J.w as a belt-and-braces safety net (J.w's actual fix was the `vi.mock('@pointmatic/quizazz', …)`), but in dev/prod mode it disables Vite's CJS→ESM dep pre-bundling for `sql.js`. Without that pre-bundling layer, the dev-server browser receives `sql.js@1.13+`'s raw UMD `dist/sql-wasm-browser.js`, whose CJS/AMD export branches don't run in pure browser ESM — so `.default` is `undefined` and `initSqlJsFn(...)` throws `TypeError: initSqlJsFn is not a function`. The exclude is now gated on `process.env.VITEST` so vitest 4.x still skips the dep-optimizer (preserving the J.w WASM-magic-header fix) while dev/prod regain CJS-interop.
17
+ - **`src/lib/db/database.ts` — typed `CjsEsmInteropError` backstop.** The dynamic `import('sql.js')` site now reads `.default` defensively and throws a named `CjsEsmInteropError` instead of an opaque `TypeError` when the initializer is missing. So the *next* sql.js drift surfaces a self-describing error in the dev-server console rather than the previous unactionable shape.
18
+
19
+ ### Verified
20
+
21
+ - `pnpm exec vitest run` → 278 passed (was 277; +1 new contract test for the CJS/ESM interop guard).
22
+ - `pnpm exec svelte-check` → 0 errors, 0 warnings.
23
+ - `pnpm exec vite build` → succeeds (confirms the gated `optimizeDeps.exclude` doesn't break prod build; the J.w comment's "covers both prod build" framing was incorrect).
24
+ - Manual: dev-server `/{moduleId}/{lessonId}` route renders without 500 in the d802-deep-learning consumer (verification scheduled with the consumer team — captured as a `[ ]` follow-up).
25
+
26
+ ## [0.79.1] - 2026-05-22
27
+
28
+ Fix five pre-existing `svelte-check` and `vitest` failures uncovered after the J.v post-assessment work landed (Story J.w). All five are type-only or test-only — no runtime behaviour changes.
29
+
30
+ ### Fixed
31
+
32
+ - **`vite.config.ts` typed `test` block.** `defineConfig` is now imported from `vitest/config` so the `test` field type-checks under `svelte-check`. The redundant `/// <reference types="vitest" />` triple-slash directive is removed in the same edit. A top-level `optimizeDeps: { exclude: ['sql.js'] }` is added as a safety belt for future scenarios where learningfoundry-owned code statically imports `sql.js`.
33
+ - **`LessonView.test.ts` lesson cast.** Replaced the brittle `Parameters<typeof render>[1] extends { props: infer P } ? P : never` conditional cast (which now resolves to `never` under `@testing-library/svelte`'s updated `render` signature) with the file-local convention `as unknown as never` already used by the J.b tagline tests.
34
+ - **`database.test.ts` `fake-indexeddb` subpath import.** `fake-indexeddb@6.x` ships type declarations only at the package root and `/auto`, not at `/lib/FDBFactory`. Switched to the typed named export: `import { IDBFactory as FDBFactory } from 'fake-indexeddb';`.
35
+ - **`VideoBlock.test.ts` `MockPlayer` constructability.** Vitest 4.x stopped wrapping `vi.fn().mockImplementation((…) => { … })` so arrow-function impls are no longer constructable via `new`. Changed both `MockPlayer` definitions to regular `function (…) { … }` impls. The dependent `IntersectionObserver is not defined` error in the rerender test was a cascade from the same root cause and is resolved by the same fix.
36
+ - **`LessonView.test.ts` / `routes/[module]/[lesson]/page.test.ts` quizazz mock.** Added `vi.mock('@pointmatic/quizazz', …)` to both files. `@pointmatic/quizazz`'s bundled `dist/db/database.js` contains a static `import wasmUrl from 'sql.js/dist/sql-wasm.wasm?url';` — vite-only syntax. Under vitest/Node ESM the `?url` query is stripped, Node treats the `.wasm` path as an ESM WebAssembly module, and fails to resolve Emscripten's synthetic `"a"` env import. The failing tests never actually render `<QuizBlock>` (their lessons are text-only or empty), so a module-surface stub is sufficient and intercepts the chain at the quizazz boundary.
37
+
38
+ ### Verified
39
+
40
+ - `pnpm exec svelte-check` → 0 errors, 0 warnings (was 3 errors).
41
+ - `pnpm exec vitest run` → 277 passed (was 265 passed / 12 failed).
42
+ - `pyve test` → 411 passed.
43
+ - `ruff` clean, `mypy` clean.
44
+
45
+ ## [0.79.0] - 2026-05-22
46
+
47
+ Post-assessment threshold gating + soft pre-assessment convention (Story J.v). With J.u's per-module-assessment scores now persisting, locking finally consumes them: a threshold-bearing non-pre assessment gates every item that appears after it in `interleaveModuleFlow`, and the next module stays sequentially locked until the assessment passes. `role: pre` is the deliberate exception — diagnostic pre-assessments are soft-gates per the J.v sharp-edge resolution, so even an unpassed pre-assessment with a `pass_threshold` set does not lock lesson 1. The `lockedAssessments: Set<string>` prop on `<LessonList>` (added in J.t with an empty default) is finally fed real data.
48
+
49
+ ### Added
50
+
51
+ - **`lockedItemsInModule(moduleId, curriculum, progress)`** in [lib/utils/locking.ts](src/learningfoundry/sveltekit_template/src/lib/utils/locking.ts) — walks `interleaveModuleFlow` in canonical order and returns `{ lockedLessons, lockedAssessments }`. Combines two orthogonal rules: the existing `lesson_sequential` lock-on-prior-incomplete, plus the new assessment-threshold gate (once any threshold-bearing non-pre assessment is unpassed, every subsequent flow item locks — including later assessments).
52
+ - **`lockedAssessmentIds(moduleId, curriculum, progress)`** — assessments-only projection of `lockedItemsInModule`. Consumed by `<ModuleList>` and passed into `<LessonList>`'s `lockedAssessments` prop.
53
+ - **11 new locking tests** in [lib/utils/locking.test.ts](src/learningfoundry/sveltekit_template/src/lib/utils/locking.test.ts) covering the six J.v acceptance cases plus extra coverage:
54
+ - Post-assessment unrecorded → next module locked.
55
+ - Post-assessment below threshold → next module locked.
56
+ - Post-assessment at/above threshold → next module unlocked.
57
+ - Pre-assessment unrecorded (with threshold) → lesson 1 still unlocked (soft-gate).
58
+ - Two post-assessments in sequence: passing the first but not the second locks the third module.
59
+ - Threshold-null assessment is informational; never gates.
60
+ - `{before_lesson: <id>}` threshold-gate locks that lesson + everything after.
61
+ - A later assessment downstream of an unpassed earlier gate renders locked itself.
62
+ - `lockedAssessmentIds` projection matches expected set shape.
63
+ - Pre-assessment with threshold doesn't block `isModuleComplete`.
64
+ - Informational assessment inside a module doesn't lock subsequent items.
65
+
66
+ ### Changed
67
+
68
+ - **`isModuleComplete`** in `locking.ts` extends to also require every threshold-bearing non-pre assessment to be passed (per `computeAssessmentPassed`). This is the propagation channel that makes the cross-module rule work: the sequential-locking check already consults `isModuleComplete(prev)`, and now an unpassed post-gate keeps that returning false. `role: pre` exempt (soft-gate).
69
+ - **`lockedLessonIds`** refactored to a wrapper that returns `lockedItemsInModule(...).lockedLessons`. Lesson-sequential locking still works identically; the new layer adds assessment-gate locking on top.
70
+ - **[components/ModuleList.svelte](src/learningfoundry/sveltekit_template/src/lib/components/ModuleList.svelte)** — computes `lockedAssessments` via `lockedAssessmentIds(...)` and passes it into `<LessonList>` alongside the existing `lockedLessons`. This is what makes the J.t locked-state styling visible in production.
71
+
72
+ ### Notes
73
+
74
+ - **`role: pre` soft-gate is the only special case in the locking logic.** Authors who want hard pre-gating use `role: practice` with `position: { before_lesson: <id> }` — same gating effect via the generic rule, no separate code path needed (matches the project-essentials guidance under "Pre-assessments are a non-locking soft-gate by convention").
75
+ - **No backwards-compat shim.** Pre-J.v modules with threshold-bearing assessments-without-scores had previously been treated as "complete" (the threshold was recorded but not gating in v1). Post-J.v those modules are `not_complete` until the assessment passes. Acceptable pre-1.0; downstream curricula that relied on the old behaviour can either drop the `pass_threshold` (making the assessment informational) or accept the gate.
76
+ - **Cross-module assessment dependencies, score-history-aware gating, and pre-assessment "skip" UI affordances remain out of scope** (matches the story spec's OOS list).
77
+ - **The pre-existing 12 vitest failures (LessonView, lesson route, VideoBlock) + 3 svelte-check errors** are still unaddressed. Story J.w now exists to track them as a `debug` cycle.
78
+
79
+ ## [0.78.0] - 2026-05-22
80
+
81
+ Per-module-assessment progress write path (Story J.u). With Story J.s's route + J.t's clickable sidebar in place, completing a module-level assessment now actually *persists*. The chosen reconciliation path (Option B per the J.u investigation gate) is a new sibling `module_assessment_scores` table keyed on `(module_id, assessment_id)` — the content-block `assessment_scores` table (keyed on global `assessment_ref`) stays as-is. The two write paths are genuinely different domains: a content-block ref is curriculum-globally unique, while two modules can legitimately reuse the same quizazz YAML so its identity at module level has to include the module id.
82
+
83
+ ### Added
84
+
85
+ - **New SQLite table `module_assessment_scores`** in [database.ts](src/learningfoundry/sveltekit_template/src/lib/db/database.ts) with PK `(module_id, assessment_id)` and the standard score/maxScore/questionCount/completed_at columns. The table is created idempotently via `CREATE TABLE IF NOT EXISTS` alongside `assessment_scores` (which is unchanged).
86
+ - **`ModuleAssessmentScore` TS interface** in [types/index.ts](src/learningfoundry/sveltekit_template/src/lib/types/index.ts) carrying `moduleId`, `assessmentId`, `score`, `maxScore`, `questionCount`, `completedAt`. `AssessmentScore` is unchanged.
87
+ - **`progressRepo.markAssessmentComplete(moduleId, assessmentId, score)`** in [db/progress.ts](src/learningfoundry/sveltekit_template/src/lib/db/progress.ts) — accepts the `AssessmentScore` shape that `<AssessmentBlock>` already builds (so the route doesn't have to translate at the call site) and drops the `assessmentRef` field at the persistence boundary; the module-level table doesn't carry it.
88
+ - **`progressRepo.getAssessmentScore(moduleId, assessmentId)`** — new overload of `getAssessmentScore` returning `ModuleAssessmentScore | null`. The pre-existing single-arg variant (lookup by global `assessmentRef`) was renamed to `getAssessmentScoreByRef` to free the canonical name.
89
+ - **`computeAssessmentPassed(score, passThreshold)`** helper in [lib/utils/assessment-passed.ts](src/learningfoundry/sveltekit_template/src/lib/utils/assessment-passed.ts) — pure read-time `passed: boolean` derivation. Returns `true` when `passThreshold` is null/undefined (informational assessment), `false` when `maxScore` is 0 with a non-null threshold, else `score / maxScore >= threshold`. Read-time evaluation lets a future YAML threshold tweak re-evaluate against the active rule instead of staying frozen to whatever was true at write time.
90
+ - **`ModuleProgress.assessmentScores: Record<string, ModuleAssessmentScore>`** — the new field is keyed by `assessmentId`. `getModuleProgress` now loads from `module_assessment_scores` in addition to `lesson_progress`.
91
+ - **Route wiring** in [routes/\[module\]/assessment/\[id\]/+page.svelte](src/learningfoundry/sveltekit_template/src/routes/[module]/assessment/[id]/+page.svelte) — the J.s no-op `handleComplete` stub is replaced with the real `progressRepo.markAssessmentComplete(moduleId, assessmentId, score)` invocation, followed by `invalidateProgress(...)` so the sidebar / dashboard pick up the new score without a page reload.
92
+ - **Tests** — 9 new cases in [progress.test.ts](src/learningfoundry/sveltekit_template/src/lib/db/progress.test.ts) covering the SQL write contract (PK clause, no `assessment_ref` column), persistence side effect, collision isolation across modules sharing an `assessmentRef`, the read SQL shape, full row deserialization, null-on-no-row, and `getModuleProgress` loading the assessment-scores map. 8 new cases in [assessment-passed.test.ts](src/learningfoundry/sveltekit_template/src/lib/utils/assessment-passed.test.ts) covering all the `computeAssessmentPassed` branches. 1 new case in the route's [page.test.ts](src/learningfoundry/sveltekit_template/src/routes/[module]/assessment/[id]/page.test.ts) asserting `markAssessmentComplete` is invoked with the URL-derived `(moduleId, assessmentId)` plus the score, and that `invalidateProgress` runs afterward.
93
+
94
+ ### Changed
95
+
96
+ - **`ModuleProgress` reshape — drop `preAssessment` / `postAssessment`, add `assessmentScores`.** The pre-J.e two-slot fields had been carrying `null` since J.e generalized to the assessments-array shape; J.u retires them in favour of the keyed map. ~5 test files (`stores/progress.test.ts`, `utils/progress.test.ts`, `utils/locking.test.ts`, `components/ModuleList.test.ts`, `components/ProgressDashboard.test.ts`) updated to drop the legacy boilerplate.
97
+ - **`progressRepo.getAssessmentScore(assessmentRef)` renamed to `getAssessmentScoreByRef(assessmentRef)`** to free the canonical name for the new `(moduleId, assessmentId)` overload. There were no production consumers of the renamed method; only tests referenced it.
98
+ - **`resetProgress` SQL** — adds `DELETE FROM module_assessment_scores` to the transaction so the course-level reset truncates the new table too.
99
+ - **[tech-spec.md](docs/specs/tech-spec.md) SQLite DDL block + TypeScript types** updated to match current schema: `assessment_scores` row shape corrected to current production reality (the spec text was carrying obsolete `module_id` / `assessment_type` columns that haven't been in the actual schema since J.m.4), and the new `module_assessment_scores` table + `ModuleAssessmentScore` interface added. `ModuleProgress` interface in the spec replaces `preAssessmentScore` / `postAssessmentScore` slots with `assessmentScores: Record<string, ModuleAssessmentScore>`.
100
+ - **[features.md](docs/specs/features.md) FR-4** — sub-bullet 3 expanded to name both write paths (`saveAssessmentScore` vs. `markAssessmentComplete`) and the underlying tables; sub-bullets 1 and 6 (schema-init list, reset-truncate list) name `module_assessment_scores` alongside the existing tables.
101
+
102
+ ### Notes
103
+
104
+ - **Forward-only data-loss migration.** Pre-J.u progress in `assessment_scores` survives (that table is unchanged). The new `module_assessment_scores` table starts empty for every installed learner. There is no backfill from `assessment_scores` to `module_assessment_scores` — a curriculum that had both a content-block assessment and a module-level assessment pointing at the same YAML would record separately; this is the correct semantics (different placement contexts) but worth documenting.
105
+ - **Locking enforcement is still deferred to J.v.** This story persists the score and exposes it through `progressStore` and `getAssessmentScore(moduleId, assessmentId)`; J.v adds the `pass_threshold` gate that consumes `computeAssessmentPassed` to lock subsequent items in the module flow.
106
+ - **Re-attempts overwrite.** `markAssessmentComplete`'s `ON CONFLICT … DO UPDATE` keeps only the latest score. Multi-attempt history is a deferred OOS item from the source spec; quizazz already retains per-question history in its own IndexedDB databases per the J.i terminology contract.
107
+
108
+ ## [0.77.0] - 2026-05-22
109
+
110
+ Clickable sidebar assessment row (Story J.t). With Story J.s's route in place, the sidebar's module-assessment row finally becomes the navigation control it always implied it was: a `<button>` that drives `goto('/{moduleId}/assessment/{id}')`, lights up amber when its assessment is the one currently being attempted, and renders a grey `aria-disabled` state when locked. The locking signal (`lockedAssessments`) is a prop-shaped seam wired into the component but fed an empty set until Story J.v ships the actual gate logic.
111
+
112
+ ### Added
113
+
114
+ - **`currentPosition.assessmentId`** in [stores/curriculum.ts](src/learningfoundry/sveltekit_template/src/lib/stores/curriculum.ts) — `NavPosition` gains an optional `assessmentId: string | null` field; `lessonId` widens to `string | null`. Mutual-exclusion is enforced by the setters: `navigateTo(...)` writes `{ moduleId, lessonId, assessmentId: null }`, the new `setAssessmentPosition(moduleId, assessmentId)` writes `{ moduleId, lessonId: null, assessmentId }`. Lesson-side derived stores (`currentLesson`, `currentIndex`) treat null `lessonId` as "not on a lesson" and return null / -1.
115
+ - **Clickable assessment row** in [components/LessonList.svelte](src/learningfoundry/sveltekit_template/src/lib/components/LessonList.svelte) — the static `<li>` chip is now an inner `<button>`. `onclick` calls `goto('/${moduleId}/assessment/${assessment.id}')` unless the row is locked. Active state lights up the **amber palette** (`bg-amber-100 font-medium text-amber-800`, ◆ in `text-amber-600`) when `$currentPosition.moduleId === moduleId && $currentPosition.assessmentId === assessment.id` — intentionally distinct from the blue lesson-active palette so learners can tell at a glance whether they're in an assessment or a lesson. Locked state: `cursor-not-allowed text-gray-300`, `aria-disabled="true"`, ◆ also greyed; click is a no-op.
116
+ - **`lockedAssessments?: Set<string>` prop** on `LessonList.svelte` — defaults to empty. Story J.v's locking pass will populate this from `locking.ts`; until then the seam exists so consumers can wire it without LessonList shape changes later.
117
+ - **Route → store sync** in [routes/\[module\]/assessment/\[id\]/+page.svelte](src/learningfoundry/sveltekit_template/src/routes/[module]/assessment/[id]/+page.svelte) — `onMount` + `$effect` call `setAssessmentPosition(moduleId, assessmentId)` when the matched assessment exists. Mirrors the lesson route's URL→store pattern; without it the sidebar amber-active state would never light up in production.
118
+ - **6 new LessonList tests** under `describe('LessonList mount — clickable assessment rows (Story J.t)')` — button shape, click navigation target, amber active state via a real `currentPosition` writable mock, default gray palette when inactive, locked-state attributes/styles/click suppression, and the `lockedAssessments` default-empty behaviour.
119
+ - **3 new curriculum-store tests** under `describe('setAssessmentPosition (Story J.t)')` — sets `{ moduleId, lessonId: null, assessmentId }` without calling `goto`, and the two mutual-exclusion transitions (lesson → assessment, assessment → lesson) clear the opposing field.
120
+
121
+ ### Changed
122
+
123
+ - **[components/navigation.helpers.ts](src/learningfoundry/sveltekit_template/src/lib/components/navigation.helpers.ts)** — `resolveGoNext` / `resolveGoPrev` add a `lessonId` non-null guard. Lesson-sequence positions are always lesson-only at runtime, so this is a TS-shape concession to the new `NavPosition.lessonId: string | null`; behaviour unchanged.
124
+ - **Existing `LessonList.test.ts` interleaved-rows test** — moves its `data-role` lookup from `li.getAttribute('data-role')` to `li.querySelector('[data-role]')`, since `data-role` now sits on the inner `<button>`. Existing `[data-testid="assessment-row"]` selectors keep working (the attribute moved with the role to the button).
125
+ - **Existing `curriculum.test.ts` navigateTo / navigateNext / navigatePrev assertions** — `toEqual` shapes updated to include `assessmentId: null` (the new explicit-clears-the-other-field contract).
126
+ - **Test-file Set typing** — 13 `new Set()` instances across `LessonList.test.ts` parameterized to `new Set<string>()`. Incidental cleanup; resolved 13 pre-existing svelte-check `Set<unknown>` errors that would otherwise have grown alongside the new tests.
127
+
128
+ ### Notes
129
+
130
+ - The lock determination itself is **deliberately not in this story** — `lockedAssessments` is whatever the caller passes in, default empty. Until J.v, no assessment renders locked in production. The visual state is exercised only by tests.
131
+ - The new route is now reachable both by URL and by sidebar click, but learners still can't be locked out of it — the locking story (J.v) closes that loop.
132
+ - Mid-lesson placement, keyboard-nav refinements beyond native `<button>` focus, and per-role styling beyond amber-active / grey-locked remain deferred (matches the story's "Out of scope" list).
133
+ - Pre-existing svelte-check errors went from 23 → 3 as a side effect of fixing the Set typing in tests I touched. The remaining 3 (vite.config `test` field, LessonView.test cast, fake-indexeddb declaration) predate this work.
134
+
135
+ ## [0.76.0] - 2026-05-22
136
+
137
+ Module-level assessment route layer (Story J.s). Until this story, the sidebar's module-assessment rows had nowhere to navigate — `<AssessmentBlock>` was only invoked from inside `LessonView`'s content-block chain. v0.76.0 adds the `[module]/assessment/[id]/` route so module-level assessments are reachable by URL. Sidebar interactivity (J.t), per-module persistence wiring (J.u), and locking enforcement (J.v) land in subsequent stories.
138
+
139
+ ### Added
140
+
141
+ - **`routes/[module]/assessment/[id]/+page.svelte`** — new SvelteKit route. Derives `moduleId` / `id` from `$page.params`, looks up `module.assessments.find(a => a.id === id)` in the curriculum store, and mounts `<AssessmentBlock>` with `assessmentRef={assessment.ref}`, `manifest={assessment.content}`, `passThreshold={assessment.pass_threshold ?? 0.0}`. Header shows `{capitalizeRole(role)} Assessment`. Completion is wired to a no-op `handleComplete(score: AssessmentScore)` stub — J.u replaces it with `progressRepo.markAssessmentComplete(moduleId, id, score)`. Unknown id (or unknown module) renders "Assessment not found." (parallels the lesson 404 branch).
142
+ - **`routes/[module]/assessment/[id]/page.test.ts`** — 5 vitest cases mirroring `AssessmentBlock.test.ts`'s stub strategy. Asserts: `<AssessmentBlock>` receives correct `assessmentRef` + `manifest`; the capitalized role label renders in the header; "Assessment not found." renders on unknown assessment id and on unknown module id; the route's completion callback signature accepts `AssessmentScore` (J.u-ready contract).
143
+
144
+ ### Changed
145
+
146
+ - **[tech-spec.md](docs/specs/tech-spec.md)** Package Structure tree — adds the new `assessment/[id]/+page.svelte` route under `[module]/` alongside `[lesson]/`.
147
+
148
+ ### Notes
149
+
150
+ - The progress-store write path (`markAssessmentComplete`) is deliberately out of scope. `<AssessmentBlock>` still persists per-assessment scores via `progressRepo.saveAssessmentScore` (its standard behaviour, untouched here); only the higher-level "module-assessment completed" hook is stubbed. J.u introduces the new `(moduleId, assessmentId)` key shape so two modules' assessments can share a `ref` without collision.
151
+ - The new route is reachable only by typing the URL directly. The sidebar's assessment row stays a static `<li>` until Story J.t.
152
+ - Pre-existing vitest failures in `LessonView.test.ts`, `VideoBlock.test.ts`, and `routes/[module]/[lesson]/page.test.ts` (12 cases) predate this story and are unrelated. Documented for visibility; their fix belongs in a separate `debug` cycle.
153
+
154
+ ## [0.75.0] - 2026-05-22
155
+
156
+ Stable per-assessment identifier for module-level assessments (Story J.r). Foundation for the upcoming assessment route layer (J.s) and the progress-store write path (J.u): both need to address a single assessment within a module by something that survives author-order edits. Without an id, the route layer would have to fall back to array indices, and URLs would shift every time an author inserts or reorders an entry.
157
+
158
+ ### Added
159
+
160
+ - **`AssessmentDefinition.id` field** in [schema_v1.py](src/learningfoundry/schema_v1.py) — optional `str | None`, defaults to `None`. When omitted, a new `Module.autogen_assessment_ids` `model_validator(mode="after")` fills it in from `role`: the first assessment with a given role gets the bare role as id (`pre`, `post`, `practice`), the Nth (N>1) appends a 1-based counter (`practice-2`, `practice-3`). Explicit ids are honoured verbatim. A second pass over the populated id set raises `ValidationError` on any duplicate, naming the module id and offending id — so explicit duplicates **and** explicit ids colliding with auto-gen results both fail loud at parse time.
161
+ - **`ResolvedAssessment.id`** in [resolver.py](src/learningfoundry/resolver.py) — non-optional `str`, threaded verbatim from the parsed `AssessmentDefinition` (auto-gen guarantees a value by the time the resolver runs). Serialized into `curriculum.json` via the existing `dataclasses.asdict` path; no generator changes required.
162
+ - **`AssessmentDefinition.id` in TS types** in [lib/types/index.ts](src/learningfoundry/sveltekit_template/src/lib/types/index.ts) — non-optional `string` in the resolved JSON, matching the auto-gen guarantee.
163
+ - **README "Assessments" subsection** ([README.md](README.md)) — documents the `id` field, the auto-gen rule, and a worked example mixing auto-gen and explicit ids.
164
+ - **4 new test cases** in [tests/test_schema_v1.py](tests/test_schema_v1.py) under `TestAssessmentIdAutoGen` — covering all-omitted auto-gen, mixed explicit + omitted, duplicate explicit rejection, and explicit-colliding-with-auto-gen rejection.
165
+
166
+ ### Changed
167
+
168
+ - **[tech-spec.md](docs/specs/tech-spec.md)** — `AssessmentDefinition` model block shows the new `id` field; new `Module.autogen_assessment_ids` validator listed; `ResolvedAssessment` dataclass and curriculum.json example both include the `id` field; SvelteKit TS `AssessmentDefinition` interface updated.
169
+ - **[features.md](docs/specs/features.md)** — FR-2 "Module assessments — generalized array" subsection has a new `id` bullet covering the auto-gen rule and uniqueness enforcement.
170
+
171
+ ### Notes
172
+
173
+ - No CLI migration tool — existing curricula continue to parse unchanged because auto-gen fills in every omitted id. Authors opt into explicit ids when they want stable URLs (e.g. `diagnostic` instead of `pre`) or to lock the id against future reorderings.
174
+ - Format constraints on `id` are intentionally limited to uniqueness — no kebab-case enforcement, matching the `Lesson.id` / `Module.id` convention where format is author responsibility (those enforce kebab-case at the schema level via `_validate_id`, but assessment ids are addressed in URLs and may want author-chosen forms; revisit if a real failure mode surfaces).
175
+
10
176
  ## [0.74.1] - 2026-05-22
11
177
 
12
178
  PyYAML flow-context gotcha surfaced by downstream authoring against the J.h/J.q schema-extensions grammar. The J.h worked example used a YAML form that fails to parse — copy-paste authors hit `expected ',' or '}', but got '['` from PyYAML with no indication of the fix. Story J.q.1: docs fix + targeted validator hint, scoped to the one quirk that reproduces against the declared dependency floor.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learningfoundry
3
- Version: 0.74.1
3
+ Version: 0.79.2
4
4
  Summary: A curriculum engine that turns a YAML curriculum definition into a deployable SvelteKit learning application.
5
5
  Project-URL: Homepage, https://github.com/pointmatic/learningfoundry
6
6
  Project-URL: Repository, https://github.com/pointmatic/learningfoundry
@@ -680,9 +680,11 @@ questions:
680
680
  # ...remaining answer slots per quizazz's schema
681
681
  ```
682
682
 
683
- **What the learner sees.** At build time, learningfoundry's `QuizazzProvider` invokes quizazz's `compile_assessment` API on each referenced YAML, embeds the compiled manifest into the generated SvelteKit app's `curriculum.json`, and the frontend mounts the vendor `<QuizBlock>` component to render the assessment inline. Each completed assessment fires a score event; learningfoundry persists `{assessmentRef, score, maxScore}` to the in-browser SQLite `assessment_scores` table. quizazz manages its own per-assessment IndexedDB database for per-question detail — the two storage layers are separate by design (see [quizazz consumer-dependency-spec.md](docs/specs/quizazz/consumer-dependency-spec.md) RR-1, RR-1a, RR-1b for the full contract).
683
+ **What the learner sees.** At build time, learningfoundry's `QuizazzProvider` invokes quizazz's `compile_assessment` API on each referenced YAML, embeds the compiled manifest into the generated SvelteKit app's `curriculum.json`, and the frontend mounts the vendor `<QuizBlock>` component to render the assessment. Each completed assessment fires a score event; learningfoundry persists scores to the in-browser SQLite. quizazz manages its own per-assessment IndexedDB database for per-question detail — the two storage layers are separate by design (see [quizazz consumer-dependency-spec.md](docs/specs/quizazz/consumer-dependency-spec.md) RR-1, RR-1a, RR-1b for the full contract).
684
684
 
685
- **Pass-threshold gating.** Optional `pass_threshold: 0.0–1.0` on either embedding shape. When set, the assessment block fires its completion event upward only when the learner's `score / maxScore` clears the threshold, which is what gates lesson-completion progression in the sidebar. When omitted (the default), every completion attempt counts as "complete" regardless of score useful for self-paced check-yourself assessments where the goal is exposure to the questions rather than gating.
685
+ **Module-level assessment routes (Story J.s, J.t).** A module-level `assessments[]` entry becomes a dedicated route at `/{moduleId}/assessment/{id}` — the `id` is the explicit `id:` if you supplied one, otherwise the role-based auto-gen (`pre`, `post`, `practice`, `practice-2`, …; see [Assessments](#assessments)). The sidebar renders each module-level row as a clickable `<button>` that navigates to its route, lights up an amber palette while the assessment is the active spot in the curriculum, and renders a grey `aria-disabled` style when locked by an upstream gate (see [Content locking](#content-locking)). Content-block-level assessments (`type: assessment` inside a lesson's `content_blocks`) stay inline within the lesson page no separate route. The two persistence paths live in separate tables: content-block scores in `assessment_scores` keyed on global `assessmentRef`; module-level scores in `module_assessment_scores` keyed on `(moduleId, assessmentId)` so two modules referencing the same YAML don't collide.
686
+
687
+ **Pass-threshold gating.** Optional `pass_threshold: 0.0–1.0` on either embedding shape. Content-block-level: the assessment block fires its completion event upward only when `score / maxScore` clears the threshold, which gates lesson-completion progression in the sidebar. Module-level: the gate is broader — items after the assessment in the module flow lock until a recorded passed score, and the next sequential module stays locked until the previous module's post-assessment is passed (Story J.v). `role: pre` is the soft-gate exception — see [Content locking](#content-locking) and the `pass_threshold` bullet under [Assessments](#assessments).
686
688
 
687
689
  **Common gotchas:**
688
690
 
@@ -694,6 +696,7 @@ questions:
694
696
 
695
697
  Each module declares an `assessments[]` array; each entry carries:
696
698
 
699
+ - `id` — **optional** (Story J.r, v0.75.0+). A stable per-assessment identifier within the module, used by the route layer and the progress store. **When omitted, learningfoundry auto-generates it from `role`:** the first assessment with a given role takes the bare role (`pre`, `post`, `practice`), and subsequent same-role entries append a 1-based counter (`practice-2`, `practice-3`). Explicit ids are honoured verbatim — typical reasons to set one are to opt into a more descriptive URL segment (`diagnostic` instead of `pre`) or to lock the id against author-order shuffles. Intra-module uniqueness is enforced at build time, so duplicate explicit ids — or an explicit id that happens to collide with an auto-gen result — fail loudly with the module id and offending id.
697
700
  - `role` — open string. Conventional values: `pre`, `practice`, `post`, `checkpoint`. Surfaces as a capitalized label in the sidebar (`Pre Assessment`, `Practice Assessment`, …).
698
701
  - `position` — discriminated union:
699
702
  - `before_lessons` — anchors at the start of the module flow.
@@ -701,10 +704,37 @@ Each module declares an `assessments[]` array; each entry carries:
701
704
  - `{ before_lesson: <lesson-id> }` — anchors immediately before the named lesson.
702
705
  - `{ after_lesson: <lesson-id> }` — anchors immediately after.
703
706
  - `source`, `ref` — provider + path, same shape as `assessment` content blocks.
704
- - `pass_threshold` — optional `0.0`–`1.0`. Recorded but not gating in v1; surfaces as a `"X% to pass"` annotation on the assessment row when set.
707
+ - `pass_threshold` — optional `0.0`–`1.0`. Surfaces as a `"X% to pass"` annotation on the assessment row when set. **Gating semantics (Story J.v, v0.79.0+):**
708
+ - On any assessment with `role` other than `pre`, a `pass_threshold` makes that assessment a gate — items appearing after it in the module flow (lessons and later assessments) stay locked until a recorded score meets the threshold. A module's sequential next-module unlock consumes the same gate, so an unpassed `after_lessons` post-assessment keeps the next module locked even when every lesson is complete.
709
+ - On `role: pre`, `pass_threshold` is **non-gating** by convention — pre-assessments are diagnostic, and locking a learner out of lesson 1 behind a test they haven't earned the right to skip yet defeats the purpose. Scores are still recorded; they just don't block progression. Authors who want hard pre-gating use `role: practice` with `position: { before_lesson: <lesson-id> }`.
710
+ - Assessments without `pass_threshold` are informational — they record scores but never gate.
705
711
 
706
712
  Lesson-anchored refs (`before_lesson` / `after_lesson`) are validated against the module's `lessons` at build time — typing a wrong lesson id fails the build with the module id, role, and unknown lesson id.
707
713
 
714
+ **Worked example — `id` auto-gen vs. explicit:**
715
+
716
+ ```yaml
717
+ assessments:
718
+ - role: pre # auto-gen id = "pre"
719
+ position: before_lessons
720
+ source: quizazz
721
+ ref: assessments/mod-01-diag.yml
722
+ - role: practice # auto-gen id = "practice"
723
+ position: { before_lesson: lesson-02 }
724
+ source: quizazz
725
+ ref: assessments/mod-01-warmup.yml
726
+ - role: practice # auto-gen id = "practice-2"
727
+ position: { after_lesson: lesson-03 }
728
+ source: quizazz
729
+ ref: assessments/mod-01-checkpoint.yml
730
+ - id: final # explicit id overrides auto-gen
731
+ role: post
732
+ position: after_lessons
733
+ pass_threshold: 0.8
734
+ source: quizazz
735
+ ref: assessments/mod-01-final.yml
736
+ ```
737
+
708
738
  ### Migrating from `pre_assessment` / `post_assessment` (pre-v0.68.0)
709
739
 
710
740
  `Module.pre_assessment` and `Module.post_assessment` were removed in v0.68.0 (Story J.e). To migrate an external curriculum that pre-dates the cutover, replace each block with a single `assessments[]` entry using the `before_lessons` or `after_lessons` position:
@@ -736,11 +766,11 @@ Strict-mode Pydantic rejects an unmigrated `pre_assessment` / `post_assessment`
736
766
 
737
767
  ## Content locking
738
768
 
739
- Control access to modules and lessons with a three-level configuration hierarchy (most local wins):
769
+ Control access to modules and lessons with three orthogonal mechanisms:
740
770
 
741
771
  1. **Per-module `locked`** — explicit `true`/`false` override; trumps everything.
742
- 2. **Curriculum `locking.sequential`** — when true, module N+1 requires module N complete.
743
- 3. **Global config `locking.sequential`**project-wide default (see Configuration File below).
772
+ 2. **Sequential locking** (`locking.sequential` + `locking.lesson_sequential`) — when on, modules / lessons must be completed in order. Hierarchy: curriculum-level config beats global config (see Configuration File below).
773
+ 3. **Assessment-threshold gating (Story J.v)**a module-level assessment with `pass_threshold` set (and `role` other than `pre`) gates every item appearing after it in the module flow. The sequential rule consumes the same gate, so an unpassed `after_lessons` post-assessment keeps the next module locked even when every lesson is complete. `role: pre` is non-gating by convention — pre-assessments are diagnostic, not gates; authors who want hard pre-gating use `role: practice` with `position: { before_lesson: <id> }`.
744
774
 
745
775
  ```yaml
746
776
  curriculum:
@@ -760,9 +790,22 @@ curriculum:
760
790
  source: quizazz
761
791
  ref: assessments/assessment.yml
762
792
  pass_threshold: 0.7 # 70% required to count as passed
793
+
794
+ # Module-level post-assessment with a threshold — gates the next module
795
+ # (Story J.v). An unpassed score here keeps `mod-02` locked even after
796
+ # every lesson in `mod-01` is complete.
797
+ assessments:
798
+ - role: post
799
+ position: after_lessons
800
+ source: quizazz
801
+ ref: assessments/mod-01-post.yml
802
+ pass_threshold: 0.7
803
+
804
+ - id: mod-02
805
+ lessons: [...]
763
806
  ```
764
807
 
765
- `unlock_module_on_complete` is useful for "gateway" lessons — a single assessment that, once passed, opens the rest of the module and the next one.
808
+ `unlock_module_on_complete` is useful for "gateway" lessons — a single content-block assessment that, once passed, opens the rest of the module and the next one. It composes with assessment-threshold gating: an `after_lessons` post-assessment with `pass_threshold` still has to pass before the next module unlocks, even if the gateway lesson short-circuited the in-module lesson requirements.
766
809
 
767
810
  ---
768
811
 
@@ -651,9 +651,11 @@ questions:
651
651
  # ...remaining answer slots per quizazz's schema
652
652
  ```
653
653
 
654
- **What the learner sees.** At build time, learningfoundry's `QuizazzProvider` invokes quizazz's `compile_assessment` API on each referenced YAML, embeds the compiled manifest into the generated SvelteKit app's `curriculum.json`, and the frontend mounts the vendor `<QuizBlock>` component to render the assessment inline. Each completed assessment fires a score event; learningfoundry persists `{assessmentRef, score, maxScore}` to the in-browser SQLite `assessment_scores` table. quizazz manages its own per-assessment IndexedDB database for per-question detail — the two storage layers are separate by design (see [quizazz consumer-dependency-spec.md](docs/specs/quizazz/consumer-dependency-spec.md) RR-1, RR-1a, RR-1b for the full contract).
654
+ **What the learner sees.** At build time, learningfoundry's `QuizazzProvider` invokes quizazz's `compile_assessment` API on each referenced YAML, embeds the compiled manifest into the generated SvelteKit app's `curriculum.json`, and the frontend mounts the vendor `<QuizBlock>` component to render the assessment. Each completed assessment fires a score event; learningfoundry persists scores to the in-browser SQLite. quizazz manages its own per-assessment IndexedDB database for per-question detail — the two storage layers are separate by design (see [quizazz consumer-dependency-spec.md](docs/specs/quizazz/consumer-dependency-spec.md) RR-1, RR-1a, RR-1b for the full contract).
655
655
 
656
- **Pass-threshold gating.** Optional `pass_threshold: 0.0–1.0` on either embedding shape. When set, the assessment block fires its completion event upward only when the learner's `score / maxScore` clears the threshold, which is what gates lesson-completion progression in the sidebar. When omitted (the default), every completion attempt counts as "complete" regardless of score useful for self-paced check-yourself assessments where the goal is exposure to the questions rather than gating.
656
+ **Module-level assessment routes (Story J.s, J.t).** A module-level `assessments[]` entry becomes a dedicated route at `/{moduleId}/assessment/{id}` — the `id` is the explicit `id:` if you supplied one, otherwise the role-based auto-gen (`pre`, `post`, `practice`, `practice-2`, …; see [Assessments](#assessments)). The sidebar renders each module-level row as a clickable `<button>` that navigates to its route, lights up an amber palette while the assessment is the active spot in the curriculum, and renders a grey `aria-disabled` style when locked by an upstream gate (see [Content locking](#content-locking)). Content-block-level assessments (`type: assessment` inside a lesson's `content_blocks`) stay inline within the lesson page no separate route. The two persistence paths live in separate tables: content-block scores in `assessment_scores` keyed on global `assessmentRef`; module-level scores in `module_assessment_scores` keyed on `(moduleId, assessmentId)` so two modules referencing the same YAML don't collide.
657
+
658
+ **Pass-threshold gating.** Optional `pass_threshold: 0.0–1.0` on either embedding shape. Content-block-level: the assessment block fires its completion event upward only when `score / maxScore` clears the threshold, which gates lesson-completion progression in the sidebar. Module-level: the gate is broader — items after the assessment in the module flow lock until a recorded passed score, and the next sequential module stays locked until the previous module's post-assessment is passed (Story J.v). `role: pre` is the soft-gate exception — see [Content locking](#content-locking) and the `pass_threshold` bullet under [Assessments](#assessments).
657
659
 
658
660
  **Common gotchas:**
659
661
 
@@ -665,6 +667,7 @@ questions:
665
667
 
666
668
  Each module declares an `assessments[]` array; each entry carries:
667
669
 
670
+ - `id` — **optional** (Story J.r, v0.75.0+). A stable per-assessment identifier within the module, used by the route layer and the progress store. **When omitted, learningfoundry auto-generates it from `role`:** the first assessment with a given role takes the bare role (`pre`, `post`, `practice`), and subsequent same-role entries append a 1-based counter (`practice-2`, `practice-3`). Explicit ids are honoured verbatim — typical reasons to set one are to opt into a more descriptive URL segment (`diagnostic` instead of `pre`) or to lock the id against author-order shuffles. Intra-module uniqueness is enforced at build time, so duplicate explicit ids — or an explicit id that happens to collide with an auto-gen result — fail loudly with the module id and offending id.
668
671
  - `role` — open string. Conventional values: `pre`, `practice`, `post`, `checkpoint`. Surfaces as a capitalized label in the sidebar (`Pre Assessment`, `Practice Assessment`, …).
669
672
  - `position` — discriminated union:
670
673
  - `before_lessons` — anchors at the start of the module flow.
@@ -672,10 +675,37 @@ Each module declares an `assessments[]` array; each entry carries:
672
675
  - `{ before_lesson: <lesson-id> }` — anchors immediately before the named lesson.
673
676
  - `{ after_lesson: <lesson-id> }` — anchors immediately after.
674
677
  - `source`, `ref` — provider + path, same shape as `assessment` content blocks.
675
- - `pass_threshold` — optional `0.0`–`1.0`. Recorded but not gating in v1; surfaces as a `"X% to pass"` annotation on the assessment row when set.
678
+ - `pass_threshold` — optional `0.0`–`1.0`. Surfaces as a `"X% to pass"` annotation on the assessment row when set. **Gating semantics (Story J.v, v0.79.0+):**
679
+ - On any assessment with `role` other than `pre`, a `pass_threshold` makes that assessment a gate — items appearing after it in the module flow (lessons and later assessments) stay locked until a recorded score meets the threshold. A module's sequential next-module unlock consumes the same gate, so an unpassed `after_lessons` post-assessment keeps the next module locked even when every lesson is complete.
680
+ - On `role: pre`, `pass_threshold` is **non-gating** by convention — pre-assessments are diagnostic, and locking a learner out of lesson 1 behind a test they haven't earned the right to skip yet defeats the purpose. Scores are still recorded; they just don't block progression. Authors who want hard pre-gating use `role: practice` with `position: { before_lesson: <lesson-id> }`.
681
+ - Assessments without `pass_threshold` are informational — they record scores but never gate.
676
682
 
677
683
  Lesson-anchored refs (`before_lesson` / `after_lesson`) are validated against the module's `lessons` at build time — typing a wrong lesson id fails the build with the module id, role, and unknown lesson id.
678
684
 
685
+ **Worked example — `id` auto-gen vs. explicit:**
686
+
687
+ ```yaml
688
+ assessments:
689
+ - role: pre # auto-gen id = "pre"
690
+ position: before_lessons
691
+ source: quizazz
692
+ ref: assessments/mod-01-diag.yml
693
+ - role: practice # auto-gen id = "practice"
694
+ position: { before_lesson: lesson-02 }
695
+ source: quizazz
696
+ ref: assessments/mod-01-warmup.yml
697
+ - role: practice # auto-gen id = "practice-2"
698
+ position: { after_lesson: lesson-03 }
699
+ source: quizazz
700
+ ref: assessments/mod-01-checkpoint.yml
701
+ - id: final # explicit id overrides auto-gen
702
+ role: post
703
+ position: after_lessons
704
+ pass_threshold: 0.8
705
+ source: quizazz
706
+ ref: assessments/mod-01-final.yml
707
+ ```
708
+
679
709
  ### Migrating from `pre_assessment` / `post_assessment` (pre-v0.68.0)
680
710
 
681
711
  `Module.pre_assessment` and `Module.post_assessment` were removed in v0.68.0 (Story J.e). To migrate an external curriculum that pre-dates the cutover, replace each block with a single `assessments[]` entry using the `before_lessons` or `after_lessons` position:
@@ -707,11 +737,11 @@ Strict-mode Pydantic rejects an unmigrated `pre_assessment` / `post_assessment`
707
737
 
708
738
  ## Content locking
709
739
 
710
- Control access to modules and lessons with a three-level configuration hierarchy (most local wins):
740
+ Control access to modules and lessons with three orthogonal mechanisms:
711
741
 
712
742
  1. **Per-module `locked`** — explicit `true`/`false` override; trumps everything.
713
- 2. **Curriculum `locking.sequential`** — when true, module N+1 requires module N complete.
714
- 3. **Global config `locking.sequential`**project-wide default (see Configuration File below).
743
+ 2. **Sequential locking** (`locking.sequential` + `locking.lesson_sequential`) — when on, modules / lessons must be completed in order. Hierarchy: curriculum-level config beats global config (see Configuration File below).
744
+ 3. **Assessment-threshold gating (Story J.v)**a module-level assessment with `pass_threshold` set (and `role` other than `pre`) gates every item appearing after it in the module flow. The sequential rule consumes the same gate, so an unpassed `after_lessons` post-assessment keeps the next module locked even when every lesson is complete. `role: pre` is non-gating by convention — pre-assessments are diagnostic, not gates; authors who want hard pre-gating use `role: practice` with `position: { before_lesson: <id> }`.
715
745
 
716
746
  ```yaml
717
747
  curriculum:
@@ -731,9 +761,22 @@ curriculum:
731
761
  source: quizazz
732
762
  ref: assessments/assessment.yml
733
763
  pass_threshold: 0.7 # 70% required to count as passed
764
+
765
+ # Module-level post-assessment with a threshold — gates the next module
766
+ # (Story J.v). An unpassed score here keeps `mod-02` locked even after
767
+ # every lesson in `mod-01` is complete.
768
+ assessments:
769
+ - role: post
770
+ position: after_lessons
771
+ source: quizazz
772
+ ref: assessments/mod-01-post.yml
773
+ pass_threshold: 0.7
774
+
775
+ - id: mod-02
776
+ lessons: [...]
734
777
  ```
735
778
 
736
- `unlock_module_on_complete` is useful for "gateway" lessons — a single assessment that, once passed, opens the rest of the module and the next one.
779
+ `unlock_module_on_complete` is useful for "gateway" lessons — a single content-block assessment that, once passed, opens the rest of the module and the next one. It composes with assessment-threshold gating: an `after_lessons` post-assessment with `pass_threshold` still has to pass before the next module unlocks, even if the gateway lesson short-circuited the in-module lesson requirements.
737
780
 
738
781
  ---
739
782
 
@@ -0,0 +1,100 @@
1
+ # nbfoundry
2
+
3
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
4
+
5
+ Marimo-based notebook framework for ML/DS work. One notebook source compiles into two
6
+ artifacts: a standalone runnable application and an `ExerciseBlock`-compatible artifact
7
+ that drops into a learningfoundry curriculum.
8
+
9
+ For the why, see [`docs/specs/concept.md`](docs/specs/concept.md). For the what, see
10
+ [`docs/specs/features.md`](docs/specs/features.md). For the how, see
11
+ [`docs/specs/tech-spec.md`](docs/specs/tech-spec.md).
12
+
13
+ ## Installation
14
+
15
+ `nbfoundry` targets Python 3.12.13 with the pinned Pyve + micromamba environment defined
16
+ in [`src/nbfoundry/templates/environment.yml`](src/nbfoundry/templates/environment.yml).
17
+ That one shared file ships as package data, gets copied into every scaffolded project by
18
+ `nbfoundry init`, and is the same spec the standalone artifact emitter falls back to.
19
+
20
+ ### Apple Silicon quickstart
21
+
22
+ The pinned stack defaults to Apple Silicon with Metal/MPS acceleration across PyTorch,
23
+ TensorFlow (via `tensorflow-metal`), and the bundled Keras 3 namespace from TF 2.16+.
24
+ It also ships the wider cross-project stack (HuggingFace `transformers` / `datasets` /
25
+ `peft`, Optuna, the plotly/seaborn/pyarrow utility set, dev tooling, and the
26
+ Pointmatic-internal `ml-datarefinery` package).
27
+
28
+ To verify the stack on a clean Apple Silicon machine, copy the shared env file and
29
+ `scripts/metal_smoke.py` into a fresh directory and let pyve build a micromamba-backed
30
+ env from the spec:
31
+
32
+ ```bash
33
+ mkdir nbfoundry-test && cd nbfoundry-test
34
+ mkdir scripts
35
+ cp <path-to-nbfoundry-root>/src/nbfoundry/templates/environment.yml .
36
+ cp <path-to-nbfoundry-root>/scripts/metal_smoke.py scripts/
37
+ pyve init --backend micromamba
38
+ pyve run python scripts/metal_smoke.py
39
+ ```
40
+
41
+ `pyve init --backend micromamba` reads the local `environment.yml` and provisions the
42
+ runtime env from it. The smoke script exercises PyTorch / TensorFlow / Keras against the
43
+ MPS device and then imports every other package added in Phase F (HuggingFace, Optuna,
44
+ plotly, seaborn, etc.) to assert basic availability — it doesn't import `nbfoundry`
45
+ itself, so no `pip install -e .` step is required for the verify.
46
+
47
+ Successful output ends with `all frameworks ran on MPS ✓`. If any framework or import
48
+ fails, the script reports which one and why (no MPS device, plugin not installed,
49
+ package missing from the env, etc.).
50
+
51
+ #### Cross-platform users (CUDA / CPU-only)
52
+
53
+ The shared `environment.yml` ships comment-delimited swap blocks for the framework
54
+ sections:
55
+
56
+ - **PyTorch CUDA:** drop the `pytorch` conda-forge line and add to the `pip:` block
57
+ `--extra-index-url https://download.pytorch.org/whl/cu126` + `torch` (or `.../cu128`
58
+ for CUDA 12.8).
59
+ - **TensorFlow CPU-only or Linux+CUDA:** replace the `tensorflow-macos` /
60
+ `tensorflow-metal` pip lines with `tensorflow>=2.16` (CPU-only) or
61
+ `tensorflow[and-cuda]>=2.16` (Linux + CUDA).
62
+
63
+ Both swap blocks are documented inline in the env file at the framework section.
64
+
65
+ ### Development setup (Pyve two-env)
66
+
67
+ ```bash
68
+ pyve init
69
+ pyve run pip install -e .
70
+ pyve testenv init
71
+ pyve testenv install -r requirements-dev.txt
72
+ ```
73
+
74
+ ## Usage
75
+
76
+ The CLI surface (`nbfoundry init`, `compile`, `compile-exercise`, `validate`) lands
77
+ across Phase D. See [`docs/specs/stories.md`](docs/specs/stories.md) for the
78
+ implementation roadmap.
79
+
80
+ ## Releasing to PyPI
81
+
82
+ Releases ship through [`.github/workflows/publish.yml`](.github/workflows/publish.yml),
83
+ which is triggered by pushing a `v*` tag. The workflow builds an sdist + wheel with
84
+ `hatch build` and publishes via PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/)
85
+ (OIDC, no long-lived API tokens).
86
+
87
+ One-time PyPI setup: register `nbfoundry` on PyPI and add a pending trusted publisher
88
+ under the project's *Publishing* settings — owner `pointmatic`, repository `nbfoundry`,
89
+ workflow `publish.yml`, environment `pypi`.
90
+
91
+ Per-release procedure:
92
+
93
+ 1. Land the version-bump story on `main` (package version in `src/nbfoundry/_version.py`
94
+ and a matching `CHANGELOG.md` entry).
95
+ 2. Tag the commit with the matching `v<version>` (e.g. `git tag v0.29.0 && git push origin v0.29.0`).
96
+ 3. The workflow verifies the tag matches `hatch version`, builds the distributions, and
97
+ publishes to PyPI under the `pypi` GitHub environment.
98
+
99
+ The workflow refuses to publish if the tag and `hatch version` disagree, so the only way
100
+ to ship a release is to tag the same commit that owns the version bump.