diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..308271c8 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,38 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/journal.py\" --mode post-tool", + "timeout": 20 + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/journal.py\" --mode session-start", + "timeout": 20 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PROJECT_DIR/scripts/journal.py\" --mode stop", + "timeout": 20 + } + ] + } + ] + } +} diff --git a/.claude/skills/ddrs-journal/SKILL.md b/.claude/skills/ddrs-journal/SKILL.md new file mode 100644 index 00000000..61ae5f58 --- /dev/null +++ b/.claude/skills/ddrs-journal/SKILL.md @@ -0,0 +1,78 @@ +--- +name: ddrs-journal +description: Use when a ddrs run or experiment has finished, when a journal entry in docs/journal/ has unfilled TODO fields, or when recording what a training run or experiment concluded. +--- + +# ddrs research journal + +`docs/journal/YYYY-MM.md` is the running record of what we tried and what came of +it. Facts are written for you by `scripts/journal.py` (hooked on `ddrs run` and +`ddrs experiment`). Judgement is yours. + +## What fires, and when + +| Event | What happens | +|---|---| +| A Bash call matching `ddrs run` / `ddrs experiment` returns | Any finished, unjournaled run gets a ledger row; non-smoke runs also get an entry stub. You are told which. | +| Your turn ends with a stub **this session** opened still unfilled | The Stop hook blocks and names it. | +| A session starts | Runs finished outside a session are reported. Nothing is written. | + +Smoke tests (`--max-mini-batches`, fewer than 5 epochs) and experiment *shards* +get a ledger row only. There is no question for them to answer. + +Manual: `python3 scripts/journal.py --mode status` (what is pending), +`--mode backfill` (ledger rows for history), `--mode backfill --with-stubs` +(also open entries — only for runs recent enough that you remember why). +`DDRS_JOURNAL_OFF=1` disables the hooks for one command. + +## Filling an entry + +Four fields. Keep the whole entry under ~15 lines; if the conclusion needs more, +it has earned a `docs/YYYY-MM-DD--findings.md` and the entry links to it. + +**Question** — what we wanted to find out, phrased so "no" is a possible answer. + +- Good: "Does `nse-batch` + AdaDelta beat L1 on the 2,365-gauge CONUS set?" +- Bad: "Improve NSE." That cannot come out false, so it cannot be answered. + +**What we did** — 2 to 4 bullets: the one thing under test, and what it was held +against. Name the arm it is being compared to. If nothing changed but the seed, +say that; seed spread is a result. + +**Result** — the number *next to the number it must beat*. A bare "NSE 0.72" +is not a result. The comparison is the summed-Q' baseline for the **same gauge +population** (`references/research-status.md` in `ddrs-dev` has the right bars; +the CONUS one is 0.6781 NSE / 0.7172 KGE on 2,365 gauges). Record the metric the +experiment was designed to move, not the one that happens to look best. + +**Conclusion** — supported, refuted, or inconclusive, and what changes next. +"Inconclusive" is a real and frequent answer; write it rather than rounding a +null result up into a weak positive. + +## Rules + +1. **Never invent a Question or Conclusion.** If you did not run the experiment + and cannot tell why it was run, write `unknown — run predates this entry` and + leave it. A journal that contains one confident guess is no longer evidence. +2. **A NO-GO is a result and gets a full entry.** The most valuable thing in this + repo is the leakance non-identifiability finding, which closed a line of work. + Failed and aborted runs get entries too: record the failure mode. +3. **Flag a dirty tree.** The facts block marks `**dirty**` when the run's git SHA + did not describe the working tree. That run is not reproducible; say so in the + conclusion rather than quietly citing its number later. +4. **Watch for the stale-binary trap.** A run whose SHA looks current can still + have executed an old `~/.cargo/bin/ddrs`. Before trusting a surprising result, + check the checkpoint layout (see `ddrs-dev` trap T1) and note what you checked. +5. **Link, don't duplicate.** If a findings doc, spec, or figure directory exists, + point at it. The journal is the index of the research, not a second copy of it. +6. **Append only.** Never rewrite a past entry's conclusion. If it turns out to be + wrong, write a new entry saying so and link back. The record of having been + wrong is part of the research. + +## What this is not + +Not a changelog (that is git), not a run log (that is `.ddrs/runs//run.log`), +and not a substitute for updating `ddrs-dev` when a run establishes a durable +fact. CLAUDE.md's rule still holds: knowledge from a completed run belongs in the +skill library in the same session that produced it. The journal records *that we +learned it and when*; the skill records *what to do about it*. diff --git a/CLAUDE.md b/CLAUDE.md index 5079a0e8..44247669 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -574,6 +574,37 @@ Subsequent plans on the same input set are cache hits and instant for both. Implementation: `src/baseline/`. Mirrors `~/projects/ddr/scripts/summed_q_prime.py`. +## Research journal (`docs/journal/`, added 2026-09-10) + +One file per month, append-only, recording what we tried and what came of it. +Two parts per month: a **ledger** with one row per completed run or experiment +(including smoke tests and shards, so nothing is lost), and **entries** carrying +four judgement fields — Question, What we did, Result, Conclusion. + +`scripts/journal.py` writes the facts, scraped from `.ddrs/runs//manifest.json` +and `.ddrs/experiments///manifest.json`. **It never writes judgement**, +and neither should you invent it: an entry for a run whose purpose is unrecoverable +says so. Hooks in `.claude/settings.json` run it after `ddrs run` / `ddrs experiment` +(ledger row always; a `_TODO_` stub for anything that is not a smoke test), at +session start (reports runs finished outside a session, writes nothing), and at +turn end (blocks only on stubs the current session opened). `DDRS_JOURNAL_OFF=1` +disables it for one command. + +```bash +python3 scripts/journal.py --mode status # what is pending +python3 scripts/journal.py --mode backfill # ledger rows for anything missed +python3 scripts/test_journal.py # stdlib only; no tier gate needed +``` + +A hook only sees commands Claude runs. A run started in your own terminal is +caught at the next session start, not immediately. Entries before 2026-09-10 are +ledger-only by construction. + +The journal is the index of the research, not a replacement for it: long-form +results still earn a `docs/YYYY-MM-DD--findings.md`, and a durable fact +still belongs in the skill library in the same session that produced it. Rules for +writing an entry: `.claude/skills/ddrs-journal/SKILL.md`. + ## Conventions specific to this repo - **Read DDR first when porting.** Cite line numbers in comments @@ -590,11 +621,12 @@ Implementation: `src/baseline/`. Mirrors ## When in doubt -- **Two skills cover this repo.** `ddrs-dev` — building, coding, configuring, +- **Three skills cover this repo.** `ddrs-dev` — building, coding, configuring, testing, running, debugging; its `references/` carry the full config reference, the change→gate matrix, the trap catalog, and the authoritative research-status - numbers. `ddrs-eval-plots` — evaluating and visualizing run output. The other 16 - skills were consolidated into these two on 2026-07-30; see + numbers. `ddrs-eval-plots` — evaluating and visualizing run output. + `ddrs-journal` — recording what an experiment concluded (see §Research journal). + The other 16 skills were consolidated on 2026-07-30; see `docs/2026-07-30-docs-and-skills-audit.md`. - Sparse / autograd questions → `docs/reference/burn-autograd.md` - Algorithm questions → `.claude/ARCHITECTURE.md` and `~/projects/ddr/CLAUDE.md` diff --git a/docs/journal/2026-06.md b/docs/journal/2026-06.md new file mode 100644 index 00000000..a9767f6a --- /dev/null +++ b/docs/journal/2026-06.md @@ -0,0 +1,38 @@ +# Research journal — 2026-06 + +Facts are written automatically from run manifests by `scripts/journal.py`. +The Question / What we did / Result / Conclusion fields are judgement and are +written by hand. See `.claude/skills/ddrs-journal/SKILL.md` for what makes a +good entry. Append-only: on a merge conflict, keep both sides. + +## Ledger + +Every completed run, including smoke tests, so nothing is lost. + + +| finished (UTC) | id | kind | result | git | +|---|---|---|---|---| +| 2026-06-02 02:36 | `2026-06-02T02-10-30Z-train-and-test` | run | NSE 0.6937 | `de7e80b`+dirty | +| 2026-06-02 13:14 | `2026-06-02T12-48-52Z-train-and-test` | run | NSE 0.6937 / KGE 0.7206 | `6ad4d41`+dirty | +| 2026-06-03 10:13 | `2026-06-03T09-45-09Z-train-and-test` | run | NSE 0.6937 / KGE 0.7206 | `61ae38d`+dirty | +| 2026-06-03 13:39 | `2026-06-03T13-13-26Z-train-and-test` | run | NSE 0.6978 / KGE 0.7233 | `856930a`+dirty | +| 2026-06-04 00:05 | `2026-06-03T23-38-42Z-train-and-test` | run | NSE 0.6980 / KGE 0.7234 | `30501af`+dirty | +| 2026-06-04 01:01 | `2026-06-04T00-35-13Z-train-and-test` | run | NSE 0.6980 / KGE 0.7234 | `4728dd8`+dirty | +| 2026-06-04 01:29 | `2026-06-04T01-03-23Z-train-and-test` | run | NSE 0.7001 / KGE 0.7238 | `4728dd8`+dirty | +| 2026-06-04 02:23 | `2026-06-04T01-57-45Z-train-and-test` | run | NSE 0.7001 / KGE 0.7239 | `15f7d00`+dirty | +| 2026-06-04 17:13 | `2026-06-04T16-46-58Z-train-and-test` | run | NSE 0.6996 / KGE 0.7238 | `3b907f1`+dirty | +| 2026-06-04 22:31 | `2026-06-04T22-31-49Z-train-and-test` | run | status `failed` | `27b36e0`+dirty | +| 2026-06-04 23:25 | `2026-06-04T22-58-43Z-train-and-test` | run | NSE 0.7004 / KGE 0.7243 | `9422006`+dirty | +| 2026-06-05 01:40 | `2026-06-05T01-40-41Z-train-and-test` | run | status `failed` | `9422006`+dirty | +| 2026-06-05 02:07 | `2026-06-05T01-41-16Z-train-and-test` | run | NSE 0.7004 / KGE 0.7244 | `6588b9f`+dirty | +| 2026-06-23 02:29 | `2026-06-23T01-43-07Z-conus-hourly-train-and-test` | run | trained, no eval metrics | `ba6b3c1`+dirty | +| 2026-06-23 02:59 | `2026-06-23T02-46-56Z-conus-hourly-train` | run | trained, no eval metrics | `a5972d9`+dirty | +| 2026-06-23 03:59 | `2026-06-23T02-49-12Z-conus-hourly-train-and-test` | run | NSE 0.7153 / KGE 0.7104 | `a5972d9`+dirty | +| 2026-06-23 13:56 | `2026-06-23T13-03-35Z-train-and-test` | run | NSE 0.6957 / KGE 0.6926 | `3c33994`+dirty | +| 2026-06-24 01:13 | `2026-06-24T00-03-01Z-conus-hourly-train-and-test` | run | NSE 0.7101 / KGE 0.7099 | `19955d1`+dirty | +| 2026-06-24 03:39 | `2026-06-24T02-10-49Z-conus-hourly-train-and-test` | run | NSE 0.7151 / KGE 0.7086 | `8908f0b`+dirty | + + +## Entries + + diff --git a/docs/journal/2026-07.md b/docs/journal/2026-07.md new file mode 100644 index 00000000..0d002d2c --- /dev/null +++ b/docs/journal/2026-07.md @@ -0,0 +1,44 @@ +# Research journal — 2026-07 + +Facts are written automatically from run manifests by `scripts/journal.py`. +The Question / What we did / Result / Conclusion fields are judgement and are +written by hand. See `.claude/skills/ddrs-journal/SKILL.md` for what makes a +good entry. Append-only: on a merge conflict, keep both sides. + +## Ledger + +Every completed run, including smoke tests, so nothing is lost. + + +| finished (UTC) | id | kind | result | git | +|---|---|---|---|---| +| 2026-07-01 01:56 | `2026-07-01T01-07-17Z-train-and-test` | run | NSE 0.6990 / KGE 0.7234 | `2cdd341` | +| 2026-07-01 02:47 | `2026-07-01T01-58-03Z-train-and-test` | run | NSE 0.6990 / KGE 0.7234 | `2cdd341` | +| 2026-07-01 15:00 | `2026-07-01T13-43-32Z-train-and-test` | run | NSE 0.7145 / KGE 0.7150 | `b5bd087`+dirty | +| 2026-07-01 22:19 | `2026-07-01T21-20-27Z-train-and-test` | run | NSE 0.6963 / KGE 0.7250 | `b5bd087`+dirty | +| 2026-07-02 11:28 | `2026-07-02T11-27-48Z-daily-lstm-train` | run | trained, no eval metrics | `ed6653c` | +| 2026-07-02 11:32 | `2026-07-02T11-30-33Z-hourly-lstm-train` | run | trained, no eval metrics | `ed6653c` | +| 2026-07-02 11:32 | `2026-07-02T11-32-36Z-hourly-lstm-train` | run | status `failed` | `ed6653c` | +| 2026-07-02 11:40 | `2026-07-02T11-40-16Z-daily-lstm-train` | run | trained, no eval metrics | `ed6653c`+dirty | +| 2026-07-02 11:40 | `2026-07-02T11-40-47Z-hourly-lstm-train` | run | status `failed` | `ed6653c`+dirty | +| 2026-07-07 04:49 | `2026-07-07T03-55-53Z-train-and-test` | run | NSE 0.5894 / KGE 0.6219 | `b261e1d` | +| 2026-07-07 06:50 | `2026-07-07T04-49-19Z-train-and-test` | run | NSE 0.6201 / KGE 0.5969 | `b261e1d` | +| 2026-07-07 09:51 | `2026-07-07T06-50-28Z-train-and-test` | run | NSE 0.5543 / KGE 0.4852 | `b261e1d` | +| 2026-07-10 17:22 | `2026-07-10T14-33-16Z-train-and-test` | run | NSE 0.7294 / KGE 0.7028 | `7cc4081`+dirty | +| 2026-07-11 05:37 | `2026-07-11T02-45-27Z-train-and-test` | run | NSE 0.7274 / KGE 0.7051 | `98f464c` | +| 2026-07-11 17:08 | `2026-07-11T14-20-47Z-train-and-test` | run | NSE 0.7281 / KGE 0.7112 | `98f464c`+dirty | +| 2026-07-12 04:25 | `2026-07-12T02-25-05Z-train-and-test` | run | NSE 0.6777 / KGE 0.6785 | `98f464c`+dirty | +| 2026-07-13 00:12 | `2026-07-12T18-25-24Z-train-and-test` | run | NSE 0.6766 / KGE 0.7223 | `98f464c`+dirty | +| 2026-07-13 07:08 | `2026-07-13T00-15-47Z-train-and-test` | run | NSE 0.7330 / KGE 0.7188 | `9178934` | +| 2026-07-16 10:49 | `2026-07-16T02-23-20Z-train-and-test` | run | NSE 0.5259 / KGE 0.5175 | `ec84987`+dirty | +| 2026-07-16 14:47 | `2026-07-16T11-31-52Z-train-and-test` | run | NSE 0.5543 / KGE 0.4852 | `ec84987`+dirty | +| 2026-07-29 07:11 | `2026-07-29T01-39-25Z-train-and-test` | run | NSE 0.6734 / KGE 0.6845 | `98ac814`+dirty | +| 2026-07-30 00:55 | `2026-07-30T00-54-17Z-train` | run | trained, no eval metrics | `1242216`+dirty | +| 2026-07-30 08:22 | `2026-07-30T00-24-24Z-train-and-test` | run | NSE 0.6799 / KGE 0.7194 | `b12c3ff`+dirty | +| 2026-07-30 20:46 | `2026-07-30T01-58-07Z-train-and-test` | run | NSE 0.6209 / KGE 0.6903 | `b12c3ff`+dirty | +| 2026-07-31 14:04 | `2026-07-31T00-54-47Z-train-and-test` | run | NSE 0.6143 / KGE 0.6853 | `24cb4d0`+dirty | + + +## Entries + + diff --git a/docs/journal/2026-08.md b/docs/journal/2026-08.md new file mode 100644 index 00000000..f83e55f0 --- /dev/null +++ b/docs/journal/2026-08.md @@ -0,0 +1,32 @@ +# Research journal — 2026-08 + +Facts are written automatically from run manifests by `scripts/journal.py`. +The Question / What we did / Result / Conclusion fields are judgement and are +written by hand. See `.claude/skills/ddrs-journal/SKILL.md` for what makes a +good entry. Append-only: on a merge conflict, keep both sides. + +## Ledger + +Every completed run, including smoke tests, so nothing is lost. + + +| finished (UTC) | id | kind | result | git | +|---|---|---|---|---| +| 2026-08-01 17:04 | `2026-08-01T03-01-18Z-train-and-test` | run | NSE 0.6855 / KGE 0.7207 | `6fd31c0`+dirty | +| 2026-08-02 12:37 | `2026-08-02T00-13-35Z-train-and-test` | run | NSE 0.6758 / KGE 0.7179 | `a7fe36e`+dirty | +| 2026-08-03 17:00 | `2026-08-03T13-11-00Z-train-and-test` | run | NSE 0.6738 / KGE 0.6960 | `e91541a`+dirty | +| 2026-08-04 17:06 | `2026-08-04T13-53-07Z-train-and-test` | run | NSE 0.6782 / KGE 0.7186 | `5858d15`+dirty | +| 2026-08-04 21:03 | `2026-08-04T17-53-55Z-train-and-test` | run | NSE 0.7043 / KGE 0.7433 | `feeba13`+dirty | +| 2026-08-05 13:22 | `2026-08-05T04-58-58Z-conus-experimental-train-and-test` | run | NSE 0.6201 / KGE 0.6986 | `a2a1c65`+dirty | +| 2026-08-09 09:30 | `2026-08-09T03-05-54Z-train-and-test` | run | NSE 0.7058 / KGE 0.7296 | `54cd386`+dirty | +| 2026-08-09 12:04 | `2026-08-09T09-30-39Z-train-and-test` | run | NSE 0.7071 / KGE 0.7379 | `54cd386`+dirty | +| 2026-08-09 14:38 | `2026-08-09T12-05-08Z-train-and-test` | run | NSE 0.5775 / KGE 0.6160 | `54cd386`+dirty | +| 2026-08-09 22:13 | `2026-08-09T22-13-26Z-train-and-test` | run | status `failed` | `54cd386`+dirty | +| 2026-08-10 04:59 | `2026-08-09T22-13-43Z-train-and-test` | run | NSE 0.4833 / KGE 0.5039 | `8140f0d`+dirty | +| 2026-08-17 04:07 | `2026-08-16T21-23-48Z-train-and-test` | run | NSE 0.5694 / KGE 0.5342 | `e44cd53`+dirty | +| 2026-08-17 12:40 | `2026-08-17T02-03-02Z-train-and-test` | run | NSE 0.7344 / KGE 0.7567 | `e44cd53`+dirty | + + +## Entries + + diff --git a/docs/journal/2026-09.md b/docs/journal/2026-09.md new file mode 100644 index 00000000..dfd777e2 --- /dev/null +++ b/docs/journal/2026-09.md @@ -0,0 +1,232 @@ +# Research journal — 2026-09 + +Facts are written automatically from run manifests by `scripts/journal.py`. +The Question / What we did / Result / Conclusion fields are judgement and are +written by hand. See `.claude/skills/ddrs-journal/SKILL.md` for what makes a +good entry. Append-only: on a merge conflict, keep both sides. + +## Ledger + +Every completed run, including smoke tests, so nothing is lost. + + +| finished (UTC) | id | kind | result | git | +|---|---|---|---|---| +| 2026-09-04 03:26 | `adjoint/2026-09-04T03-26-45Z` | experiment | 1 arm(s), status `success` | `cfdd7c8`+dirty | +| 2026-09-04 03:30 | `adjoint/2026-09-04T03-28-48Z` | experiment | 5 arm(s), status `success` | `cfdd7c8`+dirty | +| 2026-09-04 03:33 | `adjoint/2026-09-04T03-31-00Z` | experiment | 5 arm(s), status `success` | `cfdd7c8`+dirty | +| 2026-09-04 13:26 | `adjoint/2026-09-04T13-25-01Z` | experiment | 5 arm(s), status `success` | `cfdd7c8`+dirty | +| 2026-09-05 16:23 | `adjoint-conus/2026-09-05T16-23-26Z` | experiment | 5 arm(s), status `success` | `55fccd0`+dirty | +| 2026-09-05 16:31 | `adjoint/2026-09-05T16-29-39Z` | experiment | 5 arm(s), status `success` | `91b006a` | +| 2026-09-05 17:06 | `adjoint-conus/2026-09-05T16-28-57Z` | experiment | 5 arm(s), status `success` | `e3dc0ba`+dirty | +| 2026-09-05 19:26 | `adjoint-uh-validate/2026-09-05T17-59-05Z` | experiment | 1 arm(s), status `success` | `87b9dc1` | +| 2026-09-07 17:56 | `adjoint-uh-w90/2026-09-07T17-55-39Z` | experiment | 1 arm(s), status `failed` | `3e34154`+dirty | +| 2026-09-07 17:57 | `adjoint-uh-w180/2026-09-07T17-55-39Z` | experiment | 1 arm(s), status `failed` | `3e34154`+dirty | +| 2026-09-07 17:59 | `adjoint-uh-w90/2026-09-07T17-58-47Z` | experiment | 1 arm(s), status `success` | `7adbceb`+dirty | +| 2026-09-07 18:01 | `adjoint-uh-w180/2026-09-07T17-58-47Z` | experiment | 1 arm(s), status `success` | `7adbceb`+dirty | +| 2026-09-07 18:04 | `adjoint-uh-w90/2026-09-07T18-03-55Z` | experiment | 1 arm(s), status `success` | `360dc06`+dirty | +| 2026-09-07 18:07 | `adjoint-uh-w180/2026-09-07T18-03-55Z` | experiment | 1 arm(s), status `success` | `360dc06`+dirty | +| 2026-09-07 18:09 | `adjoint-uh-trace/2026-09-07T18-09-08Z` | experiment | 1 arm(s), status `success` | `360dc06`+dirty | +| 2026-09-07 18:12 | `adjoint-uh-w90/2026-09-07T18-11-30Z` | experiment | 1 arm(s), status `success` | `cf9e453`+dirty | +| 2026-09-07 18:14 | `adjoint-uh-w90/2026-09-07T18-13-59Z` | experiment | 1 arm(s), status `success` | `cf9e453`+dirty | +| 2026-09-07 18:18 | `adjoint-uh-w180/2026-09-07T18-14-59Z` | experiment | 1 arm(s), status `success` | `cf9e453`+dirty | +| 2026-09-07 18:55 | `adjoint-conus/2026-09-07T18-18-39Z` | experiment | 5 arm(s), status `success` | `4c57558` | +| 2026-09-07 19:11 | `landscape-uh-juniata/2026-09-07T19-04-07Z` | experiment | 1 arm(s), status `success` | `3c131ae`+dirty | +| 2026-09-07 19:23 | `landscape-uh-juniata-wide/2026-09-07T19-13-30Z` | experiment | 1 arm(s), status `success` | `054d47e`+dirty | +| 2026-09-07 20:51 | `adjoint-uh-seeds/2026-09-07T20-48-11Z` | experiment | 2 arm(s), status `success` | `5d8c469` | +| 2026-09-07 21:10 | `landscape-uh-juniata-wide/2026-09-07T20-57-59Z` | experiment | 2 arm(s), status `success` | `81e6398`+dirty | +| 2026-09-07 21:28 | `landscape-uh-juniata-wide/2026-09-07T21-15-36Z` | experiment | 2 arm(s), status `success` | `e69e4a3`+dirty | +| 2026-09-07 21:00 | `2026-09-07T18-45-57Z-train-and-test` | run | NSE 0.7096 / KGE 0.7426 | `56884e7`+dirty | +| 2026-09-08 15:04 | `landscape-uh-census/2026-09-08T13-52-27Z` | experiment | 2 arm(s), status `success` | `5958b0e` | +| 2026-09-08 15:15 | `landscape-uh-trajectory/2026-09-08T15-04-23Z` | experiment | 6 arm(s), status `success` | `0a908a1` | +| 2026-09-08 15:28 | `landscape-arms/2026-09-08T15-15-36Z` | experiment | 5 arm(s), status `success` | `e9586e9` | +| 2026-09-08 16:11 | `landscape-uh-juniata/2026-09-08T15-54-26Z` | experiment | 1 arm(s), status `success` | `2ac4928`+dirty | +| 2026-09-08 17:03 | `landscape-p21-reachgrad/2026-09-08T17-02-01Z` | experiment | 1 arm(s), status `success` | `740ea5d` | +| 2026-09-08 17:40 | `landscape-p21-surface-nq/2026-09-08T17-17-44Z` | experiment | 1 arm(s), status `success` | `d983739` | +| 2026-09-08 17:49 | `landscape-p21-reachgrad/2026-09-08T17-48-24Z` | experiment | 1 arm(s), status `success` | `05ea806`+dirty | +| 2026-09-08 17:34 | `2026-09-08T15-55-52Z-conus-train-and-test` | run | NSE 0.7202 / KGE 0.7540 | `d983739` | +| 2026-09-08 18:13 | `landscape-p21-reachgrad/2026-09-08T18-11-24Z` | experiment | 1 arm(s), status `success` | `692c00f`+dirty | +| 2026-09-08 18:36 | `landscape-p21-census41/2026-09-08T18-06-55Z` | experiment | 1 arm(s), status `success` | `692c00f` | +| 2026-09-08 18:41 | `landscape-p21-surface-nq/2026-09-08T18-20-07Z` | experiment | 1 arm(s), status `success` | `badee34` | +| 2026-09-08 20:45 | `landscape-pfixed-reachgrad/2026-09-08T20-43-34Z` | experiment | 1 arm(s), status `success` | `1446274` | +| 2026-09-08 20:58 | `landscape-pfixed-census41/2026-09-08T20-45-04Z` | experiment | 1 arm(s), status `success` | `1446274` | +| 2026-09-08 20:42 | `2026-09-08T14-06-12Z-train-and-test` | run | NSE 0.6999 / KGE 0.7358 | `53bff79` | +| 2026-09-08 21:08 | `landscape-p21-trajectory/2026-09-08T21-00-25Z` | experiment | 6 arm(s), status `success` | `8940a1c` | +| 2026-09-08 21:19 | `landscape-pfixed-surface-nq/2026-09-08T20-58-52Z` | experiment | 1 arm(s), status `success` | `b26938a`+dirty | +| 2026-09-09 01:52 | `landscape-p21-reachgrad/2026-09-09T01-51-19Z` | experiment | 1 arm(s), status `success` | `7352665` | +| 2026-09-09 02:14 | `landscape-p21-census41/2026-09-09T01-52-19Z` | experiment | 1 arm(s), status `success` | `8b2d9df` | +| 2026-09-09 02:34 | `landscape-p21-surface-nq/2026-09-09T02-14-52Z` | experiment | 1 arm(s), status `success` | `8b2d9df` | +| 2026-09-09 03:21 | `landscape-p21-fulltest-juniata/2026-09-09T02-55-05Z-shard-0-of-2` | experiment | 1 arm(s), status `success` | `3674648` | +| 2026-09-09 03:25 | `landscape-p21-fulltest-juniata/2026-09-09T02-55-05Z-shard-1-of-2` | experiment | 1 arm(s), status `success` | `3674648` | +| 2026-09-09 03:13 | `2026-09-09T03-13-08Z-train` | run | trained, no eval metrics | `daf8522`+dirty | +| 2026-09-09 04:02 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-23-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:03 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-19-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:04 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-7-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:05 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-4-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:06 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-12-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:07 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-15-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:08 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-8-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:09 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-9-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:09 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-17-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:09 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-0-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:09 | `landscape-p21-all/merged` | experiment | 1 arm(s), status `merged` | `964f062` | +| 2026-09-09 04:10 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-20-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:10 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-14-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:11 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-5-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:11 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-16-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:12 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-1-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:13 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-18-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:13 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-22-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:14 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-11-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:15 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-21-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:15 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-3-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:17 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-10-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:18 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-2-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:18 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-6-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 04:20 | `landscape-p21-all/2026-09-09T02-34-34Z-shard-13-of-24` | experiment | 1 arm(s), status `success` | `964f062` | +| 2026-09-09 15:20 | `landscape-p21-all-5yr/2026-09-09T04-23-57Z-shard-10-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 15:29 | `landscape-p21-all-5yr/2026-09-09T04-23-57Z-shard-12-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 15:41 | `landscape-p21-all-5yr/2026-09-09T04-23-57Z-shard-7-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 15:55 | `landscape-p21-all-5yr/2026-09-09T04-23-57Z-shard-15-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:02 | `landscape-p21-all-5yr/2026-09-09T04-23-57Z-shard-8-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:06 | `landscape-p21-all-5yr/2026-09-09T04-23-55Z-shard-1-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:07 | `landscape-p21-all-5yr/2026-09-09T04-23-56Z-shard-4-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:08 | `landscape-p21-all-5yr/2026-09-09T04-23-55Z-shard-2-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:12 | `landscape-p21-all-5yr/2026-09-09T04-23-57Z-shard-13-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:26 | `landscape-p21-all-5yr/2026-09-09T04-23-57Z-shard-14-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:29 | `landscape-p21-all-5yr/2026-09-09T04-23-57Z-shard-11-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:31 | `landscape-p21-all-5yr/2026-09-09T04-23-55Z-shard-0-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:31 | `landscape-p21-all-5yr/merged` | experiment | 1 arm(s), status `merged` | `7690da4` | +| 2026-09-09 16:38 | `landscape-p21-all-5yr/2026-09-09T04-23-55Z-shard-3-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:42 | `landscape-p21-all-5yr/2026-09-09T04-23-56Z-shard-6-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 16:46 | `landscape-p21-all-5yr/2026-09-09T04-23-57Z-shard-9-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 17:04 | `landscape-p21-all-5yr/2026-09-09T04-23-56Z-shard-5-of-16` | experiment | 1 arm(s), status `success` | `7690da4` | +| 2026-09-09 20:05 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-0-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 20:05 | `landscape-p21-top10-nse/merged-box1.5` | experiment | 1 arm(s), status `merged` | `3efa768` | +| 2026-09-09 20:19 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-5-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 20:21 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-2-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 20:23 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-9-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 20:38 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-4-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 20:41 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-1-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 20:43 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-8-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 20:43 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-3-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 20:59 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-6-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 21:06 | `landscape-p21-top10-nse/2026-09-09T19-26-48Z-shard-7-of-10` | experiment | 1 arm(s), status `success` | `3efa768` | +| 2026-09-09 21:47 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-0-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 21:47 | `landscape-p21-top10-kge/merged-box1.5` | experiment | 1 arm(s), status `merged` | `42b6406` | +| 2026-09-09 22:01 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-5-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 22:02 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-2-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 22:03 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-9-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 22:18 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-4-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 22:20 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-3-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 22:22 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-1-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 22:22 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-8-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 22:38 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-6-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 22:47 | `landscape-p21-top10-kge/2026-09-09T21-07-22Z-shard-7-of-10` | experiment | 1 arm(s), status `success` | `42b6406` | +| 2026-09-09 23:28 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-0-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-09 23:42 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-2-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-09 23:44 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-5-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-09 23:48 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-9-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-10 00:02 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-3-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-10 00:04 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-1-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-10 00:05 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-4-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-10 00:07 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-8-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-10 00:23 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-6-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-10 00:31 | `landscape-p21-top10-nse/2026-09-09T22-48-05Z-shard-7-of-10` | experiment | 1 arm(s), status `success` | `5ac69d3` | +| 2026-09-10 00:31 | `landscape-p21-top10-nse/merged` | experiment | 1 arm(s), status `merged` | `5ac69d3` | +| 2026-09-10 01:13 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-0-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 01:27 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-5-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 01:27 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-2-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 01:29 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-9-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 01:44 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-1-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 01:48 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-3-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 01:48 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-4-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 01:49 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-8-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 02:11 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-6-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 02:17 | `landscape-p21-top10-kge/2026-09-10T00-31-39Z-shard-7-of-10` | experiment | 1 arm(s), status `success` | `6ed2811` | +| 2026-09-10 02:17 | `landscape-p21-top10-kge/merged` | experiment | 1 arm(s), status `merged` | `6ed2811` | +| 2026-09-10 15:15 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-6-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 15:23 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-11-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 15:24 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-15-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 15:26 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-7-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 15:29 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-16-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 15:33 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-1-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 15:38 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-4-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 15:56 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-0-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 15:56 | `landscape-p21-report38/merged` | experiment | 1 arm(s), status `merged` | `cb1fcf3` | +| 2026-09-10 16:24 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-5-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 17:07 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-17-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 17:13 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-2-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 17:30 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-10-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 17:35 | `landscape-p21-all-trainwin-diag/2026-09-10T13-44-09Z-shard-5-of-6` | experiment | 1 arm(s), status `success` | `674d50b` | +| 2026-09-10 17:37 | `landscape-p21-all-trainwin-diag/2026-09-10T13-44-09Z-shard-1-of-6` | experiment | 1 arm(s), status `success` | `674d50b` | +| 2026-09-10 17:37 | `landscape-p21-all-trainwin-diag/2026-09-10T13-44-09Z-shard-4-of-6` | experiment | 1 arm(s), status `success` | `674d50b` | +| 2026-09-10 17:38 | `landscape-p21-all-trainwin-diag/2026-09-10T13-44-09Z-shard-0-of-6` | experiment | 1 arm(s), status `success` | `674d50b` | +| 2026-09-10 17:39 | `landscape-p21-all-trainwin-diag/2026-09-10T13-44-09Z-shard-2-of-6` | experiment | 1 arm(s), status `success` | `674d50b` | +| 2026-09-10 17:42 | `landscape-p21-all-trainwin-diag/2026-09-10T13-44-09Z-shard-3-of-6` | experiment | 1 arm(s), status `success` | `674d50b` | +| 2026-09-10 17:53 | `landscape-p21-report38/2026-09-10T13-29-23Z-shard-9-of-19` | experiment | 1 arm(s), status `success` | `cb1fcf3` | +| 2026-09-10 18:37 | `landscape-p21-report6-large/2026-09-10T17-42-30Z-shard-5-of-6` | experiment | 1 arm(s), status `success` | `51b5cf7` | +| 2026-09-10 18:59 | `landscape-p21-report6-large/2026-09-10T17-42-30Z-shard-4-of-6` | experiment | 1 arm(s), status `success` | `51b5cf7` | +| 2026-09-10 19:51 | `landscape-pfixed-all-nfield/2026-09-10T19-24-42Z-shard-0-of-6` | experiment | 1 arm(s), status `success` | `eb3f159` | +| 2026-09-10 19:51 | `landscape-pfixed-all-nfield/2026-09-10T19-24-42Z-shard-1-of-6` | experiment | 1 arm(s), status `success` | `eb3f159` | +| 2026-09-10 19:51 | `landscape-pfixed-all-nfield/2026-09-10T19-24-42Z-shard-5-of-6` | experiment | 1 arm(s), status `success` | `eb3f159` | +| 2026-09-10 19:51 | `landscape-pfixed-all-nfield/2026-09-10T19-24-42Z-shard-2-of-6` | experiment | 1 arm(s), status `success` | `eb3f159` | +| 2026-09-10 19:51 | `landscape-pfixed-all-nfield/2026-09-10T19-24-42Z-shard-4-of-6` | experiment | 1 arm(s), status `success` | `eb3f159` | +| 2026-09-10 19:52 | `landscape-pfixed-all-nfield/2026-09-10T19-24-42Z-shard-3-of-6` | experiment | 1 arm(s), status `success` | `eb3f159` | +| 2026-09-10 19:52 | `landscape-p21-report6-large/2026-09-10T17-42-30Z-shard-0-of-6` | experiment | 1 arm(s), status `success` | `51b5cf7` | +| 2026-09-10 19:52 | `landscape-p21-report6-large/merged` | experiment | 1 arm(s), status `merged` | `51b5cf7` | +| 2026-09-10 20:01 | `landscape-p21-report6-large/2026-09-10T17-42-30Z-shard-2-of-6` | experiment | 1 arm(s), status `success` | `51b5cf7` | +| 2026-09-10 20:08 | `landscape-p21-report6-large/2026-09-10T17-42-30Z-shard-3-of-6` | experiment | 1 arm(s), status `success` | `51b5cf7` | +| 2026-09-10 20:23 | `landscape-p21-report6-large/2026-09-10T17-42-30Z-shard-1-of-6` | experiment | 1 arm(s), status `success` | `51b5cf7` | +| 2026-09-10 23:31 | `2026-09-10T21-21-48Z-conus-train-and-test` | run | NSE 0.7377 / KGE 0.7600 | `130330e` | + + +## Entries + + + +--- + +### 2026-09-09 · Gridded DDM30 routing ported as a data source + +**Facts** (auto-equivalent; this entry was written by hand, the work spanned +tests and CLI runs rather than one manifest) + +- PR #40 on `worktree-gridded-routing`; mirrors DeepGroundwater/ddr#194 +- bundle `examples/juniata_gridded/`, CONUS source `config/sources/conus-gridded.yaml` +- gates: `cargo test` 630 passed, `compare_ddr_sandbox` ABSOLUTE MATCH, + `gridded_acceptance` NSE 0.7511 / KGE 0.7299 +- artifacts: `docs/superpowers/specs/2026-09-08-ddrs-gridded-routing-design.md` + +**Question:** Can ddrs route DDR's ISIMIP DDM30 gridded network without adding an +`engine/` module, purely by treating the gridded sub-reach store as another data +source, and does it reproduce DDR's numbers? + +**What we did:** + +- Mapped DDR's sub-reach adjacency zarr onto the existing `SubdividedAdjacency` + layout (parent = cell, pieces = sub-reaches) instead of writing new machinery, + leaving the routing, training, eval and baseline paths untouched. +- Held ddrs against DDR-Python on the same committed Juniata gridded bundle, and + used the deterministic summed-Q' baseline as the cross-implementation check, + since it involves no learned parameters and no RNG. +- Ran the CONUS gridded source to a mechanics smoke only, not to convergence. + +**Result:** Baseline **0.594 NSE / 0.672 KGE** against DDR's **0.594 / 0.671** — +agreement to rounding through two independent reader stacks. Routed, seed 42: +**0.751 / 0.730**; seeds {42, 7, 123} span 0.744–0.758 / 0.728–0.732, against +DDR-Python's 0.795 / 0.737. CONUS builds 5,198 cells into 91,867 sub-reaches in +0.6 s at baseline median NSE 0.354. + +**Conclusion:** Supported for ingestion — the matching baseline is strong evidence +the readers and topology are right, and it is what caught two real bugs +(`upstream_comids` counting each divide once per sub-reach, and DDR's gridded Q' +stores being `Qr(time, divide_id)`, the transpose of the contract, which zarrs +answers with fill values rather than an error). The routed gap to DDR is *not* +explained and is only partly attributable to RNG window streams: ddrs runs the +KAN head per cell where DDR runs it per sub-reach with accumulated +`log10_uparea`, and applies no `da_ratio` correction. Both deviations are +recorded in §7 of the spec. Next: decide whether per-sub-reach head + `da_ratio` +is worth matching, or whether the per-cell form is defensible on its own terms. +Do not cite the routed gridded numbers as a ddrs-vs-DDR comparison until that is +settled — they are different models, not the same model at different precision. diff --git a/docs/journal/README.md b/docs/journal/README.md new file mode 100644 index 00000000..1b84b22f --- /dev/null +++ b/docs/journal/README.md @@ -0,0 +1,35 @@ +# Research journal + +What we tried, what came out, and what we concluded — one file per month, +append-only, newest at the bottom. + +Each month has two parts: + +- **Ledger** — one row per completed run or experiment, including smoke tests + and shards, so no run is ever lost. Written automatically from + `.ddrs/runs//manifest.json` and `.ddrs/experiments///manifest.json`. +- **Entries** — the four judgement fields (Question, What we did, Result, + Conclusion) for runs that were actually answering something. Written by hand. + +`scripts/journal.py` writes the facts; it never writes judgement. A stub with +`_TODO_` fields is an open question, not a finished entry. + +```bash +python3 scripts/journal.py --mode status # what is pending +python3 scripts/journal.py --mode backfill # ledger rows for anything missed +python3 scripts/test_journal.py # stdlib only, no deps +``` + +Hooks in `.claude/settings.json` run it after `ddrs run` / `ddrs experiment`, at +session start, and at turn end. `DDRS_JOURNAL_OFF=1` disables them for one +command. + +Entries from before 2026-09-10 are ledger-only: the journal was introduced then, +and the reasoning behind earlier runs is not recoverable. It was not invented. + +Rules for writing an entry: `.claude/skills/ddrs-journal/SKILL.md`. +Long-form results still get their own `docs/YYYY-MM-DD--findings.md`; the +journal entry links to it rather than repeating it. + +Not in `SUMMARY.md` on purpose — this is a working notebook, not published +documentation. diff --git a/docs/superpowers/specs/2026-09-10-research-journal-design.md b/docs/superpowers/specs/2026-09-10-research-journal-design.md new file mode 100644 index 00000000..dee45744 --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-research-journal-design.md @@ -0,0 +1,129 @@ +# Research journal — design + +**Date:** 2026-09-10 +**Status:** implemented +**Problem:** 89 runs and ~20 experiment bundles exist under `.ddrs/`, and the +reasoning behind almost all of them lives only in conversation history, which is +compacted away. CLAUDE.md already requires that knowledge from a completed run be +committed rather than left in a findings doc or a transcript, but nothing enforced +it and nothing made it cheap. + +## 1. What this is + +A per-month, append-only notebook at `docs/journal/YYYY-MM.md` with two parts: + +- **Ledger** — one row per completed run or experiment. Machine-written. +- **Entries** — Question / What we did / Result / Conclusion. Human-written. + +The split is the whole design. Everything a manifest knows is written for you; +everything it cannot know is left explicitly blank. A `_TODO_` field is an open +question, not a formatting defect. + +## 2. Dataflow + +``` + .ddrs/runs//manifest.json .ddrs/experiments///manifest.json + run_id, workflow, status, git, study, status, backend, git, arms, + finished_at, max_mini_batches, shard, shards, notes, finished_utc + metrics{epochs, median_nse, + median_kge, n_gauges, secs} + | | + +----------------------+-----------------------+ + | + scripts/journal.py scan() + | + +--------------+--------------+ + | | + smoke test? real experiment? + (--max-mini-batches set, (everything else) + epochs < 5, or a shard) | + | | + ledger row only ledger row + entry stub + | | _TODO_ x4 + +--------------+--------------+ + | + docs/journal/YYYY-MM.md (git-tracked) + | + | <-- judgement written here, by hand + v + Question / What we did / Result / Conclusion +``` + +Trigger surface: + +``` + Bash call returns, cmd matches /ddrs (run|experiment)/ --> post-tool + writes rows + stubs, tells Claude which need filling + + turn ends, a stub THIS session opened is still _TODO_ --> stop + blocks once (stop_hook_active guard), names the ids + + session starts --> session-start + reports finished-but-unjournaled runs, WRITES NOTHING +``` + +## 3. Why a hook rather than a rule + +A CLAUDE.md rule saying "write a journal entry after every run" is the design that +already failed: the existing findings docs exist for campaigns someone remembered +to write up, and nothing for the other 200 runs. The hook makes the factual half +unconditional and independent of whether the session compacts, which agent ran the +command, or whether anyone remembers. + +Rejected alternatives: + +- **`ddrs journal` Rust subcommand.** Most robust, but it puts documentation + plumbing in the routing crate and buys a permanent tier-C gate on a concern that + has nothing to do with the solver. +- **One file per entry.** Never merge-conflicts and matches the `docs/*-findings.md` + convention, but loses the read-it-straight-through quality that makes a journal + worth keeping. Monthly rotation is the compromise: conflicts are bounded and + always resolved by taking both sides. +- **Full entry for every run.** At the observed run rate most entries would read + "smoke test, mechanics OK, no conclusion" and bury the real findings. + +## 4. Blast radius + +| Touched | Risk | Mitigation | +|---|---|---| +| `.claude/settings.json` (new) | Hooks fire on **every** Bash call in the repo | Regex-gated to `ddrs run`/`ddrs experiment`; returns in ~30 ms otherwise; `DDRS_JOURNAL_OFF=1` kill switch; top-level `except` exits 0 so a journal bug can never fail the command it observes | +| `scripts/journal.py` (new) | Could corrupt the record it exists to keep | `scripts/test_journal.py` covers idempotency, smoke classification, and that judgement is never auto-written | +| `docs/journal/**` (new) | Merge conflicts across parallel worktrees | Append-only, monthly, `---`-separated; conflict resolution is always "keep both" | +| `.ddrs/journal-state.json` (new) | none — gitignored under the existing `.ddrs/` rule | — | +| `CLAUDE.md`, `docs/SUMMARY.md` | Journal deliberately **not** added to `SUMMARY.md` | It is a working notebook; mdBook publishes reference docs | +| `src/**`, `Cargo.toml`, CI | **Untouched.** No Rust, no build, no gate. | Invariants 1–7 are not in scope | + +## 5. Assumptions + +- **The journal is git-tracked.** CLAUDE.md's reproducibility rule requires + research knowledge to be committed; a gitignored journal would repeat the + failure it exists to fix. +- **A NO-GO earns a full entry.** The leakance non-identifiability result is the + repo's most valuable finding and it is a negative one. Failed and aborted runs + get entries recording the failure mode. +- **Smoke tests have no question.** `--max-mini-batches` runs and experiment + shards are mechanics checks and slices, not results. They keep a ledger row so + provenance is complete, and nothing more. +- **History cannot be backfilled with judgement.** `--mode backfill` writes ledger + rows only. The 224 pre-existing records got provenance, not invented hypotheses. + +## 6. Known gaps + +1. **A hook only sees commands Claude runs.** `ddrs run` typed into a terminal + produces no `PostToolUse`. Caught at the next session start instead, so capture + is eventual rather than immediate. +2. **Long runs are backgrounded.** `PostToolUse` fires when the tool call returns, + which for a backgrounded run is before the manifest exists. Handled by scanning + for *any* finished-and-unjournaled run on every matching command rather than + only the run that command started, plus the `Stop` hook. +3. **The Stop hook can only nag the session that caused the stub.** By design: a + session with no context on a run would otherwise be pressured into inventing a + hypothesis, which is the one failure mode that makes the record worthless. +4. **Experiment ledger rows are thin** (`N arm(s), status`) because experiment + manifests carry no scalar result. Their results live in `figures/`. + +## 7. Outcome + +Backfilled 224 historical records into ledger rows across `2026-06.md` through +`2026-09.md`; no judgement fields invented. First real entry written by hand for +the gridded DDM30 port. `scripts/test_journal.py`: all pass. diff --git a/scripts/journal.py b/scripts/journal.py new file mode 100755 index 00000000..c44f9f8a --- /dev/null +++ b/scripts/journal.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +"""Research journal: record what we ran, what came out, and what we concluded. + +Machine facts come from run/experiment manifests. The judgement fields +(Question / What we did / Result / Conclusion) are left as TODO for a human or +for Claude to fill in -- this script never invents them. + +Modes (all read a Claude Code hook payload on stdin except backfill/status): + + --mode post-tool after a Bash call: journal any finished, unjournaled run + --mode session-start at session start: report unjournaled runs, write nothing + --mode stop at turn end: nag only about stubs this session created + --mode backfill one-off: ledger rows for every historical run + --mode status human-readable summary, no writes + +Stdlib only, no git subprocess: hooks must be fast and must not perturb the +worktree they run in. +""" +from __future__ import annotations + +import json +import os +import re +import sys +from datetime import datetime, timezone +from pathlib import Path + +# A run with fewer than this many epochs, or any run capped with +# --max-mini-batches, is a mechanics smoke test: it gets a ledger row but no +# stub, because there is no question for it to answer. +SMOKE_EPOCHS = 5 + +LEDGER_START = "" +LEDGER_END = "" +ENTRIES_MARK = "" +TODO = "_TODO_" + + +# -------------------------------------------------------------------------- +# Layout +# -------------------------------------------------------------------------- +def repo_root() -> Path: + """Nearest ancestor holding Cargo.toml (works in a worktree).""" + override = os.environ.get("DDRS_JOURNAL_ROOT") + if override: + return Path(override).resolve() + for d in [Path(__file__).resolve().parent, *Path(__file__).resolve().parents]: + if (d / "Cargo.toml").is_file(): + return d + return Path(__file__).resolve().parent.parent + + +def main_tree(root: Path) -> Path: + """The primary checkout. In a worktree, .git is a file pointing into it.""" + dot = root / ".git" + if dot.is_file(): + try: + line = dot.read_text(encoding="utf-8").strip() + except OSError: + return root + if line.startswith("gitdir:"): + gitdir = line.split(":", 1)[1].strip() + marker = "/.git/worktrees/" + if marker in gitdir: + return Path(gitdir.split(marker)[0]) + return root + + +def workspaces(root: Path) -> list[Path]: + """Every .ddrs this repo might write runs into, nearest first.""" + out, seen = [], set() + for cand in (root / ".ddrs", main_tree(root) / ".ddrs"): + rp = cand.resolve() + if cand.is_dir() and rp not in seen: + seen.add(rp) + out.append(cand) + return out + + +def journal_dir(root: Path) -> Path: + return root / "docs" / "journal" + + +def state_path(root: Path) -> Path: + """Gitignored: which stubs this session opened, so --mode stop is precise.""" + return root / ".ddrs" / "journal-state.json" + + +# -------------------------------------------------------------------------- +# Records +# -------------------------------------------------------------------------- +class Record: + def __init__(self, rid, kind, finished, status, git, facts, metrics, smoke, path): + self.id = rid + self.kind = kind # "run" | "experiment" + self.finished = finished # str, UTC + self.status = status + self.git = git # dict sha/branch/dirty + self.facts = facts # list[str] auto bullets + self.metrics = metrics # short one-line result summary + self.smoke = smoke + self.path = path + + def sort_key(self): + return (self.finished or "", self.id) + + +def _load(p: Path): + try: + return json.loads(p.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _fmt_secs(s) -> str: + if not isinstance(s, (int, float)) or s <= 0: + return "" + m = s / 60.0 + return f"{m:.0f} min" if m >= 1 else f"{s:.0f} s" + + +def _short(sha) -> str: + return (sha or "")[:7] or "?" + + +def read_run(mp: Path) -> Record | None: + d = _load(mp) + if not d or not d.get("finished_at"): + return None + m = d.get("metrics") or {} + git = d.get("git") or {} + epochs = m.get("epochs_completed") + smoke = d.get("max_mini_batches") is not None or ( + isinstance(epochs, int) and epochs < SMOKE_EPOCHS + ) + + nse, kge = m.get("median_nse_finite"), m.get("median_kge_finite") + parts = [] + if isinstance(nse, (int, float)): + parts.append(f"NSE {nse:.4f}") + if isinstance(kge, (int, float)): + parts.append(f"KGE {kge:.4f}") + if parts: + summary = " / ".join(parts) + elif d.get("status") != "ok": + summary = f"status `{d.get('status')}`" + else: + summary = "trained, no eval metrics" + + facts = [f"workflow `{d.get('workflow')}`, status `{d.get('status')}`"] + if d.get("exit_reason"): + facts.append(f"exit reason: {d['exit_reason']}") + dirty = " **dirty**" if git.get("dirty") else " clean" + facts.append(f"git `{_short(git.get('sha'))}`{dirty} on `{git.get('branch')}`") + + shape = [] + if isinstance(epochs, int): + shape.append(f"{epochs} epochs") + ng = m.get("n_gauges_finite_nse") + nt = m.get("n_gauges_total") + if isinstance(ng, int): + shape.append(f"{ng:,} gauges" + (f" of {nt:,}" if isinstance(nt, int) and nt != ng else "")) + t1, t2 = _fmt_secs(m.get("phase1_seconds")), _fmt_secs(m.get("phase2_seconds")) + if t1 or t2: + shape.append("train " + (t1 or "-") + " / eval " + (t2 or "-")) + if shape: + facts.append(", ".join(shape)) + if isinstance(nse, (int, float)): + mean = m.get("mean_nse_finite") + line = f"median NSE **{nse:.4f}**" + if isinstance(kge, (int, float)): + line += f", median KGE **{kge:.4f}**" + if isinstance(mean, (int, float)): + line += f" (mean NSE {mean:.4f})" + facts.append(line) + if d.get("source_lock", {}).get("drift"): + facts.append("⚠ source drift recorded against the lockfile") + + return Record(d.get("run_id") or mp.parent.name, "run", d["finished_at"], + d.get("status"), git, facts, summary, smoke, mp.parent) + + +def read_experiment(mp: Path) -> Record | None: + d = _load(mp) + if not d or not d.get("finished_utc"): + return None + git = d.get("git") or {} + study = d.get("study") or "?" + # A shard is one slice of a study, not a result. Merged output is the result. + smoke = d.get("shard") is not None + rid = f"{mp.parent.parent.name}/{mp.parent.name}" + + arms = d.get("arms") or [] + facts = [f"study `{study}`, status `{d.get('status')}`, backend `{d.get('backend')}`"] + dirty = " **dirty**" if git.get("dirty") else " clean" + facts.append(f"git `{_short(git.get('sha'))}`{dirty} on `{git.get('branch')}`") + if arms: + names = ", ".join(f"`{a.get('name')}`" for a in arms[:4]) + more = f" (+{len(arms) - 4} more)" if len(arms) > 4 else "" + facts.append(f"{len(arms)} arm(s): {names}{more}") + for a in arms[:4]: + if a.get("run_id"): + facts.append(f"arm `{a.get('name')}` ← run `{a['run_id']}` @ `{a.get('checkpoint', '?')}`") + if d.get("shards"): + facts.append(f"merged from {len(d['shards'])} shards") + for n in (d.get("notes") or [])[:5]: + facts.append(f"note: {n}") + if d.get("bundle_dir"): + facts.append(f"bundle `{d['bundle_dir']}`") + + return Record(rid, "experiment", d["finished_utc"], d.get("status"), git, + facts, f"{len(arms)} arm(s), status `{d.get('status')}`", + smoke, mp.parent) + + +def scan(root: Path) -> list[Record]: + recs, seen = [], set() + for ws in workspaces(root): + for mp in sorted(ws.glob("runs/*/manifest.json")): + r = read_run(mp) + if r and r.id not in seen: + seen.add(r.id) + recs.append(r) + for mp in sorted(ws.glob("experiments/*/*/manifest.json")): + r = read_experiment(mp) + if r and r.id not in seen: + seen.add(r.id) + recs.append(r) + recs.sort(key=Record.sort_key) + return recs + + +# -------------------------------------------------------------------------- +# Journal files +# -------------------------------------------------------------------------- +def month_of(finished: str) -> str: + m = re.match(r"(\d{4})-(\d{2})", finished or "") + return f"{m.group(1)}-{m.group(2)}" if m else datetime.now(timezone.utc).strftime("%Y-%m") + + +LEDGER_ID = re.compile(r"^\|[^|]*\|\s*`([^`]+)`\s*\|", re.M) + +# Only YYYY-MM.md files are data. README.md and anything else in the directory +# is prose and must never be parsed as ledger rows or entries. +MONTH_GLOB = "[0-9][0-9][0-9][0-9]-[0-9][0-9].md" + + +def recorded_ids(root: Path) -> set[str]: + """Ids already in the journal, from ledger rows *and* entry markers. + + A ledger-only record has no `` marker, so both sources are + needed or every backfill duplicates the whole table. + """ + ids = set() + jd = journal_dir(root) + if not jd.is_dir(): + return ids + for f in jd.glob(MONTH_GLOB): + try: + text = f.read_text(encoding="utf-8") + except OSError: + continue + ids.update(re.findall(r"", text)) + ids.update(LEDGER_ID.findall(text)) + return ids + + +def month_file(root: Path, month: str) -> Path: + return journal_dir(root) / f"{month}.md" + + +def ensure_month(root: Path, month: str) -> Path: + p = month_file(root, month) + if p.exists(): + return p + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text( + f"# Research journal — {month}\n\n" + "Facts are written automatically from run manifests by `scripts/journal.py`.\n" + "The Question / What we did / Result / Conclusion fields are judgement and are\n" + "written by hand. See `.claude/skills/ddrs-journal/SKILL.md` for what makes a\n" + "good entry. Append-only: on a merge conflict, keep both sides.\n\n" + "## Ledger\n\n" + "Every completed run, including smoke tests, so nothing is lost.\n\n" + f"{LEDGER_START}\n" + "| finished (UTC) | id | kind | result | git |\n" + "|---|---|---|---|---|\n" + f"{LEDGER_END}\n\n" + "## Entries\n\n" + f"{ENTRIES_MARK}\n", + encoding="utf-8", + ) + return p + + +def when_utc(finished: str) -> str: + """Run ids use `T02-03-02Z`, run manifests use `T02:03:02Z`. Normalise.""" + m = re.match(r"(\d{4}-\d{2}-\d{2})[T ](\d{2})[:-](\d{2})", finished or "") + return f"{m.group(1)} {m.group(2)}:{m.group(3)}" if m else (finished or "?") + + +def ledger_row(r: Record) -> str: + when = when_utc(r.finished) + dirty = "+dirty" if r.git.get("dirty") else "" + return (f"| {when} | `{r.id}` | {r.kind} | {r.metrics} | " + f"`{_short(r.git.get('sha'))}`{dirty} |") + + +def rel_path(root: Path, p: Path) -> str: + """Repo-relative where possible: absolute home paths in a committed doc are + noise, and the journal is read from other checkouts.""" + for base in (root, main_tree(root)): + try: + return str(Path(p).resolve().relative_to(base.resolve())) + except ValueError: + continue + return str(p) + + +def stub(root: Path, r: Record) -> str: + when = (r.finished or "")[:10] + facts = "\n".join(f"- {f}" for f in r.facts) + return ( + f"\n---\n\n" + f"### {when} · `{r.id}` \n\n" + f"**Facts** (auto)\n\n{facts}\n" + f"- artifacts: `{rel_path(root, r.path)}`\n\n" + f"**Question:** {TODO} — what were we trying to find out, stated so it could come out \"no\"\n\n" + f"**What we did:** {TODO} — 2–4 bullets: the change under test, and what it was held against\n\n" + f"**Result:** {TODO} — the number, next to the number it must beat\n\n" + f"**Conclusion:** {TODO} — supported / refuted / inconclusive, and what it changes next\n" + ) + + +def append(root: Path, recs: list[Record], stubs: bool = True) -> list[tuple[Record, bool]]: + """Write ledger rows for all; stubs for non-smoke. Returns (rec, got_stub). + + `stubs=False` is for backfill: the judgement behind a months-old run is not + recoverable, and an entry nobody can honestly fill is worse than no entry. + """ + out = [] + by_month: dict[str, list[Record]] = {} + for r in recs: + by_month.setdefault(month_of(r.finished), []).append(r) + + for month, rs in sorted(by_month.items()): + p = ensure_month(root, month) + text = p.read_text(encoding="utf-8") + rows = "".join(ledger_row(r) + "\n" for r in rs) + if LEDGER_END in text: + text = text.replace(LEDGER_END, rows + LEDGER_END, 1) + else: # file was hand-edited past recognition; never lose the data + text += "\n" + rows + for r in rs: + want = stubs and not r.smoke + if want: + text += stub(root, r) + out.append((r, want)) + p.write_text(text, encoding="utf-8") + return out + + +def open_todos(root: Path) -> list[str]: + """Entry ids whose judgement fields are still unfilled.""" + ids = [] + for f in sorted(journal_dir(root).glob(MONTH_GLOB)) if journal_dir(root).is_dir() else []: + try: + text = f.read_text(encoding="utf-8") + except OSError: + continue + for block in text.split("\n---\n"): + m = re.search(r"", block) + if m and TODO in block: + ids.append(m.group(1)) + return ids + + +# -------------------------------------------------------------------------- +# Session state (gitignored) -- so --mode stop only nags about our own stubs +# -------------------------------------------------------------------------- +def load_state(root: Path) -> dict: + return _load(state_path(root)) or {} + + +def save_state(root: Path, st: dict) -> None: + p = state_path(root) + try: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(json.dumps(st, indent=1), encoding="utf-8") + except OSError: + pass + + +# -------------------------------------------------------------------------- +# Modes +# -------------------------------------------------------------------------- +def emit(event: str, context: str) -> None: + json.dump({"hookSpecificOutput": {"hookEventName": event, + "additionalContext": context}}, sys.stdout) + sys.stdout.write("\n") + + +TRIGGER = re.compile(r"\bddrs\b[^|;&]*\b(run|experiment)\b|--bin\s+(train|train_and_test|eval)\b") + + +def mode_post_tool(root: Path, payload: dict) -> int: + cmd = (payload.get("tool_input") or {}).get("command") or "" + if not TRIGGER.search(cmd): + return 0 + pending = [r for r in scan(root) if r.id not in recorded_ids(root)] + if not pending: + return 0 + written = append(root, pending) + + st = load_state(root) + sid = payload.get("session_id") or "?" + mine = st.setdefault("stubs_by_session", {}).setdefault(sid, []) + mine.extend(r.id for r, got in written if got) + save_state(root, st) + + stubs = [r.id for r, got in written if got] + lines = [f"Research journal: recorded {len(written)} completed run(s) in docs/journal/."] + if stubs: + lines.append("These need the judgement fields written before this turn ends " + "(Question / What we did / Result / Conclusion):") + lines += [f" - {i}" for i in stubs] + lines.append("Fill them from what you actually know. If you do not know why the " + "run was made, say so in the entry rather than inventing a hypothesis.") + else: + lines.append("All were smoke tests: ledger rows only, no entry needed.") + emit("PostToolUse", "\n".join(lines)) + return 0 + + +def mode_session_start(root: Path, payload: dict) -> int: + done = recorded_ids(root) + pending = [r for r in scan(root) if r.id not in done and not r.smoke] + todos = open_todos(root) + if not pending and not todos: + return 0 + lines = [] + if pending: + lines.append(f"Research journal: {len(pending)} finished run(s) are not in the journal " + "(likely run outside a Claude session):") + lines += [f" - {r.id} — {r.metrics}" for r in pending[-8:]] + lines.append("Run `python3 scripts/journal.py --mode backfill` to record them, " + "then write the judgement fields for any that answered a question.") + if todos: + lines.append(f"{len(todos)} journal entr(ies) still have TODO judgement fields: " + + ", ".join(todos[-6:])) + emit("SessionStart", "\n".join(lines)) + return 0 + + +def mode_stop(root: Path, payload: dict) -> int: + if payload.get("stop_hook_active"): + return 0 # already nagged once this turn; never loop + sid = payload.get("session_id") or "?" + st = load_state(root) + mine = set((st.get("stubs_by_session") or {}).get(sid) or []) + if not mine: + return 0 + unfilled = [i for i in open_todos(root) if i in mine] + if not unfilled: + return 0 + json.dump({"decision": "block", "reason": + "These journal entries were opened by runs in this session and still have " + "TODO judgement fields: " + ", ".join(unfilled) + + ". Fill Question / What we did / Result / Conclusion in docs/journal/ now. " + "Write what you actually know; if the run's purpose is unclear to you, " + "record that plainly instead of inventing one."}, sys.stdout) + sys.stdout.write("\n") + return 0 + + +def mode_backfill(root: Path, _payload: dict) -> int: + """Ledger rows for history. Never stubs -- see append()'s docstring. + + Pass --with-stubs to open entries too; only sensible for runs recent enough + that someone still remembers why they were made. + """ + pending = [r for r in scan(root) if r.id not in recorded_ids(root)] + if not pending: + print("Journal is up to date.") + return 0 + with_stubs = "--with-stubs" in sys.argv + written = append(root, pending, stubs=with_stubs) + stubs = [r.id for r, got in written if got] + print(f"Recorded {len(written)} run(s) in the ledger" + + (f"; {len(stubs)} opened as entries needing judgement." if stubs else + " (ledger only -- rerun with --with-stubs to open entries).")) + for i in stubs: + print(f" {i}") + return 0 + + +def mode_status(root: Path, _payload: dict) -> int: + recs = scan(root) + done = recorded_ids(root) + todos = open_todos(root) + print(f"workspaces : {', '.join(str(w) for w in workspaces(root))}") + print(f"journal : {journal_dir(root)}") + print(f"runs found : {len(recs)} ({sum(1 for r in recs if r.smoke)} smoke)") + print(f"recorded : {len(done)}") + print(f"unrecorded : {sum(1 for r in recs if r.id not in done)}") + print(f"open TODOs : {len(todos)}") + for i in todos: + print(f" {i}") + return 0 + + +MODES = {"post-tool": mode_post_tool, "session-start": mode_session_start, + "stop": mode_stop, "backfill": mode_backfill, "status": mode_status} + + +def main() -> int: + mode = "status" + if "--mode" in sys.argv: + mode = sys.argv[sys.argv.index("--mode") + 1] + if mode not in MODES: + print(f"unknown mode {mode!r}; pick one of {', '.join(MODES)}", file=sys.stderr) + return 2 + payload = {} + if mode in ("post-tool", "session-start", "stop") and not sys.stdin.isatty(): + try: + payload = json.loads(sys.stdin.read() or "{}") + except ValueError: + payload = {} + root = repo_root() + if os.environ.get("DDRS_JOURNAL_OFF"): + return 0 + return MODES[mode](root, payload) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: # a journal must never break the thing it observes + print(f"journal.py: {type(exc).__name__}: {exc}", file=sys.stderr) + sys.exit(0) diff --git a/scripts/test_journal.py b/scripts/test_journal.py new file mode 100644 index 00000000..a1a80ca4 --- /dev/null +++ b/scripts/test_journal.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Tests for scripts/journal.py. Stdlib only: python3 scripts/test_journal.py + +Covers the behaviours that would silently corrupt the research record: +idempotency, smoke-test classification, that judgement fields are never +invented, and that the Stop nag is scoped to the session that caused it. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +JOURNAL = Path(__file__).resolve().parent / "journal.py" +FAILURES: list[str] = [] + + +def check(name: str, cond: bool, detail: str = "") -> None: + if cond: + print(f" ok {name}") + else: + print(f" FAIL {name} {detail}") + FAILURES.append(name) + + +def run(root: Path, mode: str, payload=None, extra=()): + env = {**os.environ, "DDRS_JOURNAL_ROOT": str(root)} + p = subprocess.run( + [sys.executable, str(JOURNAL), "--mode", mode, *extra], + input=json.dumps(payload or {}), capture_output=True, text=True, env=env) + return p + + +def write_run(root: Path, rid: str, *, finished: str, epochs=30, nse=0.72, + kge=0.75, status="ok", max_mb=None): + d = root / ".ddrs" / "runs" / rid + d.mkdir(parents=True, exist_ok=True) + (d / "manifest.json").write_text(json.dumps({ + "run_id": rid, "workflow": "train-and-test", "status": status, + "finished_at": finished, "max_mini_batches": max_mb, + "git": {"sha": "abc1234def", "dirty": False, "branch": "master"}, + "metrics": {"epochs_completed": epochs, "median_nse_finite": nse, + "median_kge_finite": kge, "n_gauges_finite_nse": 2365, + "phase1_seconds": 3888.0, "phase2_seconds": 2019.0}, + })) + + +def write_experiment(root: Path, study: str, ts: str, *, finished: str, shard=None): + d = root / ".ddrs" / "experiments" / study / ts + d.mkdir(parents=True, exist_ok=True) + (d / "manifest.json").write_text(json.dumps({ + "study": study, "status": "success", "finished_utc": finished, + "backend": "cpu", "shard": shard, "notes": [], + "git": {"sha": "feed1234", "dirty": True, "branch": "master"}, + "arms": [{"name": "a1", "run_id": "r1", "checkpoint": "epoch_30_mb_1"}], + })) + + +def journal_text(root: Path) -> str: + jd = root / "docs" / "journal" + return "".join(f.read_text() for f in sorted(jd.glob("*.md"))) if jd.is_dir() else "" + + +def main() -> int: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "Cargo.toml").write_text("[package]\n") + + print("scan + classification") + write_run(root, "2026-09-01T00-00-00Z-train-and-test", finished="2026-09-01T02:00:00Z") + write_run(root, "2026-09-02T00-00-00Z-smoke", finished="2026-09-02T02:00:00Z", + epochs=2, max_mb=2) + write_run(root, "2026-08-30T00-00-00Z-train-and-test", finished="2026-08-30T02:00:00Z") + write_experiment(root, "landscape", "2026-09-03T00-00-00Z", finished="2026-09-03T01-00-00Z") + write_experiment(root, "landscape", "2026-09-03T00-00-01Z", + finished="2026-09-03T01-00-01Z", shard="0-of-4") + out = run(root, "status").stdout + check("finds every record", "runs found : 5" in out, out) + check("smoke + shard classified as smoke", "(2 smoke)" in out, out) + + print("backfill writes ledger only, never judgement") + run(root, "backfill") + text = journal_text(root) + check("ledger rows written", text.count("| 2026-") == 5, str(text.count("| 2026-"))) + check("no invented judgement", "_TODO_" not in text) + check("months split", (root / "docs/journal/2026-09.md").exists() + and (root / "docs/journal/2026-08.md").exists()) + + print("idempotency") + run(root, "backfill") + run(root, "backfill") + text = journal_text(root) + check("no duplicate rows after 3 backfills", text.count("| 2026-") == 5, + str(text.count("| 2026-"))) + + print("post-tool: trigger matching") + write_run(root, "2026-09-05T00-00-00Z-train-and-test", finished="2026-09-05T02:00:00Z") + p = run(root, "post-tool", {"session_id": "S1", "tool_input": {"command": "ls -la"}}) + check("ignores unrelated commands", p.stdout.strip() == "", p.stdout) + p = run(root, "post-tool", + {"session_id": "S1", "tool_input": {"command": "ddrs run --workflow train"}}) + check("fires on ddrs run", "Research journal" in p.stdout, p.stdout[:200]) + text = journal_text(root) + check("opens a stub for a real run", "_TODO_" in text) + check("stub carries an id marker", "" in text) + + print("post-tool: smoke runs get no stub") + write_run(root, "2026-09-06T00-00-00Z-smoke", finished="2026-09-06T02:00:00Z", + epochs=1, max_mb=2) + p = run(root, "post-tool", + {"session_id": "S1", "tool_input": {"command": "ddrs run --max-mini-batches 2"}}) + check("smoke says so", "smoke tests" in p.stdout, p.stdout[:200]) + check("smoke has no stub", "2026-09-06T00-00-00Z-smoke -->" not in journal_text(root)) + + print("stop: scoped to the session that opened the stub") + p = run(root, "stop", {"session_id": "S2"}) + check("silent for a session that opened nothing", p.stdout.strip() == "", p.stdout) + p = run(root, "stop", {"session_id": "S1"}) + check("blocks the session that did", '"decision": "block"' in p.stdout, p.stdout[:200]) + p = run(root, "stop", {"session_id": "S1", "stop_hook_active": True}) + check("never loops", p.stdout.strip() == "", p.stdout) + + print("stop: goes quiet once judgement is written") + f = root / "docs/journal/2026-09.md" + f.write_text(f.read_text().replace("_TODO_", "answered")) + p = run(root, "stop", {"session_id": "S1"}) + check("quiet after fields filled", p.stdout.strip() == "", p.stdout) + + print("session-start reports work done outside a session") + write_run(root, "2026-09-07T00-00-00Z-train-and-test", finished="2026-09-07T02:00:00Z") + p = run(root, "session-start", {"session_id": "S3"}) + check("reports unjournaled run", "2026-09-07T00-00-00Z" in p.stdout, p.stdout[:300]) + check("writes nothing itself", "2026-09-07T00-00-00Z" not in journal_text(root)) + + print("robustness") + bad = root / ".ddrs" / "runs" / "corrupt" + bad.mkdir(parents=True, exist_ok=True) + (bad / "manifest.json").write_text("{not json") + p = run(root, "status") + check("survives a corrupt manifest", p.returncode == 0, p.stderr[:200]) + env = {**os.environ, "DDRS_JOURNAL_ROOT": str(root), "DDRS_JOURNAL_OFF": "1"} + p = subprocess.run([sys.executable, str(JOURNAL), "--mode", "post-tool"], + input='{"tool_input":{"command":"ddrs run"}}', + capture_output=True, text=True, env=env) + check("DDRS_JOURNAL_OFF disables it", p.stdout.strip() == "", p.stdout) + + print() + if FAILURES: + print(f"{len(FAILURES)} FAILED: {', '.join(FAILURES)}") + return 1 + print("all journal tests pass") + return 0 + + +if __name__ == "__main__": + sys.exit(main())