diff --git a/current_export.toml b/current_export.toml index aa58598b..58089732 100644 --- a/current_export.toml +++ b/current_export.toml @@ -41,6 +41,71 @@ note = """ The standard export. Two streams, region labels, and the full per-event set the contract asks for — `time_sec` (t50rise), `width_sec` + `width_def`, `peak_sec`, `amp`. Carries no `analysis_*` columns, deliberately. + +⚠ NOT the newest folder the producer has shipped, and `default` is deliberately left +pointing here rather than moved. `steps_excluded` below is 2026-09-03 and its own README +says "for any new analysis, use this folder"; moving `default` would silently re-point +every existing analysis and every parity fixture at a different corpus. Changing it is a +decision, not a housekeeping edit — see the note on `steps_excluded`. +""" + +[steps_excluded] +# The producer's 2026-09-03 export with field-step artifacts REMOVED. +name = "2026-09-03_revised_2v_long_STEPS_EXCLUDED" +recordings = 84 +note = """ +Whole-field brightness steps GENERATE detections — onsets lock to the step, 74% (fast) +and 82% (slow) of ROIs fire at once, at 8.5x and 22.7x each slice's own rate, in both +streams. That is the signature a coordination detector exists to find, and it was in the +data. Nine steps confirmed by eye; every event within ±2 s of one removed (381 events, +9 slices), each listed in `field_steps_excluded.tsv`. + +Pairs with `2026-09-03_revised_2v_long_PRE_ARTIFACT_KILLER`, which keeps the artifacts and +is on disk so the runs made before the removal stay reproducible. `slices.csv` and +`regions.csv` are byte-identical between the two, and the 75 unaffected recordings are +byte-identical once the two new columns are stripped — so the pair is a clean control. + +Carries `analysis_start_sec` / `analysis_end_sec` (the `long_window_20` regime), which +`default` does not, plus two new per-event columns: `on_field_step` and `field_step_id`. + +⚠ THE LOADER DROPS BOTH NEW COLUMNS SILENTLY. `io.py` recognises only width_sec / +width_def / peak_sec / amp beyond roi and time_sec, and `store.Stream` has no per-event +flag field, so `csv.DictReader` picks them up and they are discarded with no error. In +THIS folder that costs nothing — every surviving row reads `0` — but it is why the +FLAGGED_FOR_REVIEW companion cannot be read correctly here yet. + +⚠ Known contamination this folder does NOT address: non-rigid motion correction pinned +12 ROIs to the frame floor across `20260629_312`, `20260629_309`, `20260630_316` and +`20250926_235`. Not flagged in any column. Producer's own caveat. +""" + +[senktide] +# First-treatment subset of `steps_excluded`. A view, not a re-export. +name = "2026-09-03_revised_2v_long_STEPS_EXCLUDED_SENKTIDE" +recordings = 29 +note = """ +Every recording in `steps_excluded` whose first non-baseline region is `senktide`, carried +WHOLE — every region, every event, full recording extent — with the sidecars subset to +match. Event data byte-identical to the parent's; nothing re-derived, re-windowed or +recounted. Groups: ORX 10, OVX 8, DI 6, MALE 5. + +The parent remains the record. Where the two disagree about anything but which recordings +are present, the parent wins. +""" + +[ttx] +# First-treatment subset of `steps_excluded`. A view, not a re-export. +name = "2026-09-03_revised_2v_long_STEPS_EXCLUDED_TTX" +recordings = 38 +note = """ +Same construction as `senktide`, on a first treatment of `TTX`. Groups: DI 11, MALE 9, +ORX 9, OVX 9. + +⚠ TTX IS NOT A SILENCING CONTROL HERE — FOUNDATIONS §9. Coordination persists under it and +splits by stream (FAST median 0.46 of baseline, SLOW median 2.50 with 44% of slices at or +above). A detector returning little in a TTX window is not thereby validated, and a nonzero +coactivity excess on these slices is evidence about the preparation, not a false-alarm floor +to tune away. """ [pensub] diff --git a/docs/INDEX.md b/docs/INDEX.md index 318df01a..6cea3f92 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -32,6 +32,8 @@ enforced by `tests/test_index_resolves.py`, which reads code spans too. | **the centrepiece — a person's verdicts on the machine's candidates** | human in the loop, machine assisted, identification, confirm, reject, annotate, verdict, judgement, who decided, agreement | [`src/bugarach/annotate.py`](../src/bugarach/annotate.py) — records the verdict **and the view it was made in**. `assess` proposes, this disposes; browser UI in `docs/site/raster_viewer.html` | | **MAHICE** | mahice, machine assisted, human identification, the centrepiece, the review, who decided | **Machine-Assisted Human Identification of Coordinated Events** — Tony's name for stage 2, and the step K is set in. **Defined in [`GLOSSARY.md`](GLOSSARY.md)** — with the retired spelling, and why its D was wrong. `annotate.MAHICE`; the record is **mahice.json** (`annotate.MahiceSession`, name in `annotate.SESSION_FILE`), which carries the K, who set it, when, and the ROI population it resolved against | | **K — who sets it, and in what units** | K, min_rois, floor, how many ROIs, coactivity floor, percentage, blocked, who chose K | **K is set by the user during MAHICE, and it is a PERCENTAGE of each recording's ROI population** (Tony, 2026-09-03). `bugarach assess --k-percent` shows the consequence; `assess.k_from_fraction` resolves it per recording *using the generator's own rule*; `derive_spec --session ` carries it into the spec. ⚠ An absolute K is not comparable across recordings — 3 of 10 ROIs is a third of the field and 3 of 51 is six percent, and both are in this corpus | +| **a FLOOR under the percentage** | k floor, minimum ROIs, max(3, 10%), clamp, K=1, two rules | `--k-floor` on `bugarach assess`, `tools/assess_archive.py` and `tools/derive_spec.py`. `k_from_fraction` clamps only at 1 and leaves the clamp to the caller — this is it. ⚠ **Read `min_rois_floor_bound` beside `min_rois`**: where the floor binds, K came from the floor and not the percentage, so a pooled number came from **two rules**. On the senktide/TTX cohort at 10% it binds on 26 of 84 | +| deriving a spec at a percentage nobody signed off on | unreviewed K, no MAHICE, skip the human, percentage_unreviewed | `derive_spec --k-percent --k-floor --unreviewed`. Aggregates ACROSS recordings at each one's own resolved count instead of selecting one column of the scan — selecting a column drops every recording whose K landed elsewhere. `k_source` reads **`percentage_unreviewed`**, never `mahice`; a consumer must be able to tell those apart | | K, cross-checked against the labels | derive_k, does my K agree, separation, Youden, second opinion | `annotate.cross_check_k` — **reports, never overrides.** `annotate.derive_k` is the arithmetic's opinion and is **not** where K comes from. ⚠ Candidates must be proposed **below** the K being set, or the labels are censored at the boundary they are meant to check — [the trap](todo/2026-08-28-derive-k-from-confirmed-events.md), and `assess --for-annotation` | | the question the loop opens with and never answers | contrast, before and after, baseline vs drug, paired, does it change, publish | ⚠ **nothing computes it.** No function in `src/` puts two regions side by side — [`the question nothing computes`](todo/2026-08-23-the-treatment-contrast-is-the-question-nothing-computes.md) | @@ -44,6 +46,9 @@ enforced by `tests/test_index_resolves.py`, which reads code spans too. | what a folder is allowed to contain | contract, columns, producer, optional fields, silent ROI | [`export_folder_spec.md`](export_folder_spec.md) | | why analysis must not read the `.mat` store | store is closed, exclusions, withdrawn recordings, dead ROI | `CLAUDE.md` "The export folder is the input"; sapper **SAP007** | | the tour of where things live on disk | data root, darkroom, mount | [`where_the_data_are.md`](where_the_data_are.md) | +| **field-step artifacts, and the folder with them removed** | artifact, brightness step, whole-field, step, excluded, artifact killer, on_field_step, field_step_id, contamination, 381 events | [`current_export.toml`](../current_export.toml) role **`steps_excluded`** (2026-09-03), and the folder's own `README.md` + `field_steps_excluded.tsv`. ⚠ Steps GENERATE detections — 74–82% of ROIs firing at once in both streams — so this is not cosmetic. ⚠ The loader **drops** `on_field_step` / `field_step_id` silently; harmless here (all zero), not harmless for the FLAGGED companion | +| the senktide-only or TTX-only cohort | senktide, ttx, first treatment, subset, split, per-treatment folder | `current_export.toml` roles **`senktide`** (29) and **`ttx`** (38) — the producer's own split of `steps_excluded` on each recording's first non-baseline region, carried whole. ⚠ TTX is **not** a silencing control (FOUNDATIONS §9) | +| ⚠ **the newest folder is not `default`** | which folder, current, newest, why is default old, should I switch | `current_export.toml`. `default` stays at `2026-08-18_revised_2v_periods` on purpose: moving it re-points every existing analysis and every parity fixture. The 2026-09-03 export is declared beside it as `steps_excluded`. **Switching `default` is Tony's call, not a housekeeping edit** | ## Another lab's data — DANDI / Cossart @@ -108,6 +113,8 @@ enforced by `tests/test_index_resolves.py`, which reads code spans too. | reviewing a document before it ships | murderboard, adversarial, roles, anti-slop | [`doc_review_process.md`](doc_review_process.md) | | **quoting someone's email, or anything a person said in private** | correspondence, personal communication, private mail, quote a letter, permission, is this public, de-identify, attribution leak | **Cite it; do not quote it.** `CLAUDE.md` §*Other people's words* — paraphrase plus `, personal communication, `. **This repo is public**, it has leaked a private letter twice, and asking afterwards is not asking. Mechanized both at the commit and in CI: [`tools/check_quotes.py`](../tools/check_quotes.py). Where the letters themselves should live is open: [`todo/2026-09-02-correspondence-has-nowhere-private-to-live.md`](todo/2026-09-02-correspondence-has-nowhere-private-to-live.md) | | **draw a figure — how this repo renders one** | figure, plot, chart, PNG, holoviews, bokeh, panel, playwright, screenshot | copy the shape of any `tools/make_*_figure.py`: `measure()` → `build()` (holoviews panels) → `header_html()` → save HTML, screenshot it with Playwright chromium. Destination **defaults to `bugarach.paths.darkroom()`**, `--also` takes the repo copy (SAP006 blocks a required `--out`). Conventions — no titles, identity in the y-label, nothing drawn on a raster — are in `CLAUDE.md` | +| ⚠ **a figure wider than 1120 px loses its right-hand column** | clipped, cut off, missing panel, right edge, facet missing, viewport, screenshot width | `_write(..., viewport_width=N)` in [`tools/make_generator_figures.py`](../tools/make_generator_figures.py). The clip was a **literal 1120.0** until 2026-09-09, so widening the viewport widened the window and clipped back anyway — no error, and a PNG that looks finished. Same shape as the height bug two comments above it in that file | +| before/after per detector, faceted by experimental group | fireflies plot, before after, paired, per group, facet, one page per detector, treatment contrast | [`tools/make_before_after_figure.py`](../tools/make_before_after_figure.py) `--per-detector --facet group`. Y shared across a stream's group facets and nowhere else. ⚠ It reports recordings the detections file never mentions — at zero they cannot be told from a detector that ran and found nothing | | an algorithm implemented from a spec alone | clean room, spec, adversarial validation | [`clean_room/`](clean_room/) | | a decision already made | ADR, ruling, settled | [`adr/`](adr/) | | open work | todo, backlog, next | `docs/todo/` — 166 files, `status:` in frontmatter. **A record, not a queue** | diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 2cb862e4..081191fa 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -10,6 +10,22 @@ cannot travel (live process ids, that box's free disk, local scratch paths). --- +### Mac/full-cohort-senktide-ttx — DARKROOM claim: `bugarach/2026-09-09-full-cohort-senktide-ttx/` +- **Status:** ACTIVE 2026-09-09. The full senktide and TTX cohorts through the loop — + assess, spec, simulate, calibrate, train, held-out comparison, detect, figures. Successor + to the APV+CNQX+GZ pilot, and the first run able to detect at its own calibration and to + run its own trained models on real data, both of which #507–#509 closed yesterday. +- **Holds:** that one darkroom directory and nothing else. No deploy, no MATLAB, no site. + `/constellation/` untouched — that is the MATLAB producer team's. +- **Input:** the producer's `2026-09-03_..._STEPS_EXCLUDED_{SENKTIDE,TTX}` folders, which + no file in this repo declared until this branch. ⚠ **`current_export.toml`'s `default` + still points at `2026-08-18_revised_2v_periods`** and is deliberately left there — moving + it would re-point every existing analysis and every parity fixture at a different corpus. + New roles were added beside it instead; changing `default` is a decision for Tony. +- **MAHICE skipped on instruction**, so K is a percentage nobody signed off on: `k_source` + reads `percentage_unreviewed`, never `mahice`. RESET.md §1 applies to everything + downstream and the artifacts say so. + ### Mac/deploy-the-figure-and-the-viewer — SITE claim RELEASED: `bugarach.tonydefazio.com` - **Status:** **RELEASED 2026-09-08. THE SITE IS HELD BY NOBODY.** Published `3cb89fd`, wrangler **Version ID `ab3cc81d-1ed5-415b-ada8-14deb77574ce`**, 7 of 8 assets changed. diff --git a/src/bugarach/assess.py b/src/bugarach/assess.py index c53fd52a..125ff2c3 100644 --- a/src/bugarach/assess.py +++ b/src/bugarach/assess.py @@ -226,6 +226,22 @@ class Assessment: a folder of mixed ROI counts the second one differs per recording while the first does not.""" + min_rois_floor: int | None = None + """An absolute floor applied under :attr:`min_rois_frac`, or ``None``. + + :func:`k_from_fraction` clamps only at 1, and its own docstring says the clamp + belongs to whoever is asking — this is that clamp, recorded rather than applied + silently. A percentage alone reaches K=1 on a small field, and one co-active ROI + is not coordination. + + **Whether it BOUND is** :attr:`min_rois_floor_bound`, and the two are meant to be + read together. A floor that never binds is a setting; a floor that binds on a + third of the corpus is a second K rule governing the small recordings, and a + number quoted over that population came from two rules rather than one.""" + + min_rois_floor_bound: bool = False + """True when the floor raised K above what the fraction resolved to here.""" + def _coact_count(trains, win_dur, bin_width, n_bins, offsets=None): """Per-bin distinct-ROI coactivity. An ROI contributes 1 to a bin if it has @@ -341,6 +357,7 @@ def assess_coactivity( region: str = "baseline", min_rois=None, min_rois_frac=None, + min_rois_floor: int | None = None, bin_width_sec: float | None = None, wm_factor: float = 1.5, merge_bins: int = 2, @@ -406,20 +423,46 @@ def assess_coactivity( raise ValueError( f"min_rois_frac must be fractions in (0, 1], got {fracs}. " f"Percentages go in as 0.10 rather than 10.") + # The floor is applied HERE, after the fraction has met this recording's + # own ROI count, because that is the only place the two can be compared. + # It is recorded on every result — `min_rois_floor` and + # `min_rois_floor_bound` — so a reader can tell a K a percentage produced + # from a K the floor produced. On a corpus of mixed field sizes those are + # different populations, and averaging across them without saying so + # reports one rule where two were running. + floor = None if min_rois_floor is None else int(min_rois_floor) + if floor is not None and floor < 1: + raise ValueError( + f"min_rois_floor must be at least 1, got {floor}. A floor below " + f"one co-active ROI is not a floor.") resolved = [(k_from_fraction(f, st.n_rois), f) for f in fracs] + bound_at = {} + if floor is not None: + bound_at = {max(floor, kk): kk < floor for kk, _ in resolved} + resolved = [(max(floor, kk), f) for kk, f in resolved] # Two fractions can land on one count in a small field — 5% and 10% of 12 # ROIs are both 1. Keeping both rows would report the same measurement # twice under different labels, so the coarser fraction wins its count - # and the duplicate is dropped rather than silently averaged. + # and the duplicate is dropped rather than silently averaged. A floor + # collapses fractions the same way and for the same reason: below it they + # all name one K. seen: dict[int, float] = {} for kk, f in resolved: seen.setdefault(kk, f) ks = tuple(sorted(seen)) frac_of: dict[int, float | None] = {kk: seen[kk] for kk in ks} + floor_of: dict[int, bool] = {kk: bound_at.get(kk, False) for kk in ks} else: + if min_rois_floor is not None: + raise ValueError( + "min_rois_floor applies to a K given as a fraction; with an " + "absolute min_rois the floor is either already in the number or " + "is a second opinion about it. Pass one.") + floor = None ks = tuple(int(K) for K in (min_rois if min_rois is not None else DEFAULT_MIN_ROIS)) frac_of = {kk: None for kk in ks} + floor_of = {kk: False for kk in ks} min_rois = ks if window is None: @@ -436,7 +479,7 @@ def assess_coactivity( if picked is None: return [Assessment(min_rois=int(K), meets_floor=False, win_dur=float("nan"), n_roi=st.n_rois, - n_events_win=0, min_rois_frac=frac_of[K]) + n_events_win=0, min_rois_frac=frac_of[K], min_rois_floor=floor, min_rois_floor_bound=floor_of[K]) for K in min_rois] win_start, win_end, win_dur = picked.win_start, picked.win_end, picked.win_dur meets = picked.meets_floor @@ -448,7 +491,7 @@ def assess_coactivity( if not meets: return [Assessment(min_rois=int(K), meets_floor=False, win_dur=win_dur, n_roi=st.n_rois, n_events_win=0, - min_rois_frac=frac_of[K]) for K in min_rois] + min_rois_frac=frac_of[K], min_rois_floor=floor, min_rois_floor_bound=floor_of[K]) for K in min_rois] bin_width = 1.0 if bin_width_sec is None else float(bin_width_sec) wm = wm_factor * bin_width @@ -531,7 +574,7 @@ def assess_coactivity( jit_obs, jit_null = _med(sd_obs), _med(sds_null[K]) defined = bool(sd_obs) and bool(sds_null[K]) out.append(Assessment( - min_rois=K, min_rois_frac=frac_of[K], + min_rois=K, min_rois_frac=frac_of[K], min_rois_floor=floor, min_rois_floor_bound=floor_of[K], meets_floor=True, win_dur=win_dur, n_roi=n_roi, n_events_win=int(sum(n_in_win)), roi_rate=roi_rate, roi_rate_med=_med(roi_rate), diff --git a/src/bugarach/assess_folder.py b/src/bugarach/assess_folder.py index 511d67e9..00dc0071 100644 --- a/src/bugarach/assess_folder.py +++ b/src/bugarach/assess_folder.py @@ -85,7 +85,8 @@ def skipped(self) -> list[RecordingAssessment]: def assess_folder(folder, *, stream: str | None = None, n_surrogates: int = 1000, bin_width_sec: float | None = None, limit: int | None = None, progress=None, - min_rois=None, min_rois_frac=None) -> FolderAssessment: + min_rois=None, min_rois_frac=None, + min_rois_floor: int | None = None) -> FolderAssessment: """Assess every recording in an export folder that may be assessed. Reads the folder with the same loader the rest of bugarach uses, so a folder @@ -172,6 +173,8 @@ def assess_folder(folder, *, stream: str | None = None, **({} if min_rois is None else {"min_rois": tuple(min_rois)}), **({} if min_rois_frac is None else {"min_rois_frac": tuple(min_rois_frac)}), + **({} if min_rois_floor is None + else {"min_rois_floor": int(min_rois_floor)}), **({} if bin_width_sec is None else {"bin_width_sec": bin_width_sec})) except Exception as e: # noqa: BLE001 rec.skipped = f"{type(e).__name__}: {e}" diff --git a/src/bugarach/cli.py b/src/bugarach/cli.py index f4641e4e..aab3a73c 100644 --- a/src/bugarach/cli.py +++ b/src/bugarach/cli.py @@ -148,6 +148,15 @@ def main(argv: list[str] | None = None) -> None: "is a third of a 10-ROI field and six percent of a " "51-ROI one, and both are in this corpus. The report " "names the count each percentage came to") + asr.add_argument("--k-floor", type=int, default=None, + help="an absolute floor under --k-percent, applied per " + "recording after the percentage meets that recording's " + "ROI count. A percentage alone reaches K=1 on a small " + "field and one co-active ROI is not coordination. The " + "report says where the floor BOUND, because a floor " + "that binds on part of the corpus means two K rules " + "were running and a number pooled over them came from " + "both") asr.add_argument("--for-annotation", action="store_true", help="scan down to K=2 instead of stopping at 3. A proposal " "list censored at the floor being estimated makes that " @@ -334,6 +343,14 @@ def main(argv: list[str] | None = None) -> None: if any(not (0.0 < f <= 1.0) for f in fracs): sys.exit(f"bugarach: --k-percent values must be in (0, 100] — " f"got {args.k_percent!r}") + if args.k_floor is not None and fracs is None: + sys.exit("bugarach: --k-floor applies to --k-percent. With an " + "absolute K the floor is either already in the number or is " + "a second opinion about it; pass one.") + if args.k_floor is not None and args.k_floor < 1: + sys.exit(f"bugarach: --k-floor must be at least 1, got " + f"{args.k_floor}. A floor below one co-active ROI is not a " + f"floor.") if fracs is not None and args.for_annotation: sys.exit("bugarach: --k-percent and --for-annotation are two ways of " "choosing the scan. Pass a low percentage instead — the " @@ -344,7 +361,7 @@ def main(argv: list[str] | None = None) -> None: n_surrogates=args.surrogates, bin_width_sec=args.bin_width, limit=args.limit, progress=_progress("assessing"), min_rois=(PROPOSAL_MIN_ROIS if args.for_annotation else None), - min_rois_frac=fracs) + min_rois_frac=fracs, min_rois_floor=args.k_floor) print(format_assessment(fa)) # Exit 0 whether or not anything was assessable. This is a MEASUREMENT, # not a gate: "no recording carried a baseline region" is an answer about diff --git a/tests/test_before_after_figure.py b/tests/test_before_after_figure.py index 307016a4..81507468 100644 --- a/tests/test_before_after_figure.py +++ b/tests/test_before_after_figure.py @@ -11,6 +11,15 @@ * a recording lacking either period is skipped and named, never drawn at zero; * a detector with no calls in a period is a zero, not a missing point, and a call outside every declared period is counted nowhere. + +And three the group facets added (2026-09-09): + +* every row carries the producer's own `group_id`, unchanged, and a recording the + folder gives no group keeps a row under ``""`` rather than being dropped; +* the facet order puts the unlabelled group last, so a page never opens on it; +* a recording the detections file never mentions is drawn at zero and is + REPORTED, because at zero it cannot be told from a detector that ran and found + nothing — one is an answer, the other is a run that did not cover it. """ from __future__ import annotations @@ -72,7 +81,7 @@ def _write(tmp_path: Path): def test_rates_are_per_minute_of_the_scored_window(tmp_path): folder, det = _write(tmp_path) rows, detectors, streams, missing = mod.rates(folder, det, baseline="baseline", treatment="APV+CNQX+GZ") - by = {(d, s, sid): (b, t) for d, s, sid, b, t, _, _ in rows} + by = {(d, s, sid): (b, t) for d, s, sid, _g, b, t, _, _ in rows} # baseline: 2 calls / 20 min; treatment: 1 call / (2820-1320)/60 = 25 min — # the producer's analysis windows, not the raw periods assert by[("coact", "fast", "s1")] == pytest.approx((2 / 20.0, 1 / 25.0)) @@ -83,7 +92,7 @@ def test_a_recording_without_the_treatment_is_skipped_and_named(tmp_path): folder, det = _write(tmp_path) rows, _, _, missing = mod.rates(folder, det, baseline="baseline", treatment="APV+CNQX+GZ") assert missing == ["s2"] - assert not any(sid == "s2" for _, _, sid, _, _, _, _ in rows) + assert not any(sid == "s2" for _, _, sid, _, _, _, _, _ in rows) def test_both_periods_are_named_by_the_caller(tmp_path): @@ -93,14 +102,14 @@ def test_both_periods_are_named_by_the_caller(tmp_path): assert rows == [] and missing == ["s1", "s2"] # and the same call with the periods swapped is a different figure, not an error rows, _, _, _ = mod.rates(folder, det, baseline="APV+CNQX+GZ", treatment="baseline") - by = {(d, s, sid): (b, t) for d, s, sid, b, t, _, _ in rows} + by = {(d, s, sid): (b, t) for d, s, sid, _g, b, t, _, _ in rows} assert by[("coact", "fast", "s1")] == pytest.approx((1 / 25.0, 2 / 20.0)) def test_no_calls_is_a_zero_not_a_gap(tmp_path): folder, det = _write(tmp_path) rows, detectors, _, _ = mod.rates(folder, det, baseline="baseline", treatment="APV+CNQX+GZ") - by = {(d, s, sid): (b, t) for d, s, sid, b, t, _, _ in rows} + by = {(d, s, sid): (b, t) for d, s, sid, _g, b, t, _, _ in rows} # loco called only in high K+, which is neither period drawn assert by[("loco", "fast", "s1")] == (0.0, 0.0) # a detection outside every declared period (region_idx NA) is not counted anywhere @@ -108,3 +117,41 @@ def test_no_calls_is_a_zero_not_a_gap(tmp_path): assert "loco" in detectors and "rate" in detectors # detectors come out in the glossary's order, not the file's assert detectors == [d for d in mod.DETECTORS if d in ("rate", "coact", "loco")] + + +def test_every_row_carries_the_producers_group(tmp_path): + folder, det = _write(tmp_path) + rows, _, _, _ = mod.rates(folder, det, baseline="baseline", + treatment="APV+CNQX+GZ") + assert rows, "the fixture drew nothing" + assert {r[3] for r in rows} == {"MALE"} + assert mod.groups_of(folder) == {"s1": "MALE", "s2": "MALE"} + + +def test_a_recording_with_no_group_is_kept_not_dropped(tmp_path): + folder, det = _write(tmp_path) + (folder / "slices.csv").write_text( + "slice_id,frame_interval_sec,group_id\ns1,0.1,\ns2,0.1,MALE\n") + rows, _, _, _ = mod.rates(folder, det, baseline="baseline", + treatment="APV+CNQX+GZ") + # A missing group is a fact about the folder. Dropping those recordings would + # shrink an n nobody was told about, so they keep rows under "". + assert {r[3] for r in rows} == {""} + assert mod.facet_groups(rows) == [""] + + +def test_the_unlabelled_facet_is_drawn_last(): + rows = [("coact", "fast", "a", "OVX", 0.0, 0.0, 0, 0), + ("coact", "fast", "b", "", 0.0, 0.0, 0, 0), + ("coact", "fast", "c", "DI", 0.0, 0.0, 0, 0)] + # Named groups in order, then the unlabelled one — so a page never opens on + # the facet that has no name to read. + assert mod.facet_groups(rows) == ["DI", "OVX", ""] + + +def test_group_inks_are_distinct_and_stable(): + a = mod.group_inks(["DI", "MALE", "ORX", "OVX"]) + assert len(set(a.values())) == 4, "four groups must not share one ink" + # The ink is the FACET's identity, so a group keeps its colour across the + # twelve pages of one run. + assert mod.group_inks(["DI", "MALE", "ORX", "OVX"]) == a diff --git a/tests/test_k_percent.py b/tests/test_k_percent.py index 481cca74..fa985203 100644 --- a/tests/test_k_percent.py +++ b/tests/test_k_percent.py @@ -265,3 +265,76 @@ def test_the_message_always_names_who_set_it_and_the_percentage(): cross_check_k(verdicts([(6, "confirmed")] * 4), session())): assert "tony" in c.message assert "% of ROIs" in c.message + + +# --- the floor under a percentage (2026-09-09) ------------------------------- +# +# `k_from_fraction` clamps only at 1 and says in its own docstring that the clamp +# belongs to whoever is asking. These pin that clamp: it applies AFTER the +# fraction has met the recording, a floor that BOUND is recorded rather than +# absorbed, and it is refused where it would be a second opinion about an +# absolute K. + + +def test_the_floor_applies_after_the_fraction_meets_the_recording(tmp_path): + from bugarach.assess import assess_coactivity + + # 10% of 10 ROIs rounds to 1, and one co-active ROI is not coordination. + small, = assess_coactivity(_slice(tmp_path, 10), window=(0.0, 1100.0), + min_rois_frac=(0.10,), min_rois_floor=3, + n_surrogates=20) + assert small.min_rois == 3 + assert small.min_rois_floor == 3 and small.min_rois_floor_bound is True + # 10% of 40 is 4, already above the floor, so the floor changed nothing and + # must not claim it did. + big, = assess_coactivity(_slice(tmp_path, 40), window=(0.0, 1100.0), + min_rois_frac=(0.10,), min_rois_floor=3, + n_surrogates=20) + assert big.min_rois == 4 + assert big.min_rois_floor == 3 and big.min_rois_floor_bound is False + + +def test_a_bound_floor_is_recorded_not_absorbed(tmp_path): + """Two recordings can share a K and have reached it by different rules. + + Without the flag the pair is indistinguishable, and a number pooled over them + reads as one setting when two were running. + """ + from bugarach.assess import assess_coactivity + + floored, = assess_coactivity(_slice(tmp_path, 10), window=(0.0, 1100.0), + min_rois_frac=(0.10,), min_rois_floor=3, + n_surrogates=20) + earned, = assess_coactivity(_slice(tmp_path, 30), window=(0.0, 1100.0), + min_rois_frac=(0.10,), min_rois_floor=3, + n_surrogates=20) + assert floored.min_rois == earned.min_rois == 3 + assert floored.min_rois_floor_bound is not earned.min_rois_floor_bound + + +def test_no_floor_leaves_the_fraction_alone(tmp_path): + from bugarach.assess import assess_coactivity + + a, = assess_coactivity(_slice(tmp_path, 10), window=(0.0, 1100.0), + min_rois_frac=(0.10,), n_surrogates=20) + assert a.min_rois == 1, "unclamped, K=1 is still reachable" + assert a.min_rois_floor is None and a.min_rois_floor_bound is False + + +def test_a_floor_under_an_absolute_k_is_refused(tmp_path): + """With an absolute K the floor is either already in the number or is a + second opinion about it, and applying it silently would move a published K.""" + from bugarach.assess import assess_coactivity + + with pytest.raises(ValueError, match="min_rois_floor applies"): + assess_coactivity(_slice(tmp_path, 10), window=(0.0, 1100.0), + min_rois=(3,), min_rois_floor=3, n_surrogates=20) + + +def test_a_floor_below_one_is_refused(tmp_path): + from bugarach.assess import assess_coactivity + + with pytest.raises(ValueError, match="at least 1"): + assess_coactivity(_slice(tmp_path, 10), window=(0.0, 1100.0), + min_rois_frac=(0.10,), min_rois_floor=0, + n_surrogates=20) diff --git a/tools/assess_archive.py b/tools/assess_archive.py index 00e9af46..a07ae8cf 100644 --- a/tools/assess_archive.py +++ b/tools/assess_archive.py @@ -104,7 +104,8 @@ def _refuse_to_overwrite_ours(folder: Path, out_name: str) -> None: def assess_store(store: Path, *, stream: str | None, n_surrogates: int, limit: int | None = None, assemblies: bool = False, assembly_surrogates: int = 1000, - min_rois=None) -> dict: + min_rois=None, min_rois_frac=None, + min_rois_floor: int | None = None) -> dict: """Assess every baseline recording under ``store``. **Takes an export folder or a `.mat` store, and prefers the folder.** The @@ -122,12 +123,39 @@ def assess_store(store: Path, *, stream: str | None, n_surrogates: int, if assemblies: from bugarach.assembly import assess_assemblies - # The floors are this lab's default unless a caller names its own. They are - # not a universal: K is a coactivity FLOOR IN CELLS, so what it means depends - # on how many cells the field has, and (3, 4, 6, 8) was chosen against ~34. - floors = tuple(sorted(set(int(k) for k in (min_rois or DEFAULT_MIN_ROIS)))) - if any(k < 2 for k in floors): - raise SystemExit(f"K must be at least 2 to be a coactivity floor: {floors}") + # K arrives one of two ways and they are not interchangeable. An absolute + # floor is a count of cells, so what it means depends on how many cells the + # field has — (3, 4, 6, 8) was chosen against ~34 and this corpus runs 10 to + # 61. A FRACTION resolves against each recording's own population, which is + # the space K is actually set in (assess.DEFAULT_MIN_ROIS_FRAC), and + # `min_rois_floor` is the clamp `k_from_fraction` deliberately leaves to the + # caller. When a fraction is given the per-recording resolution happens + # inside `assess_coactivity`, so nothing is resolved here. + by_fraction = min_rois_frac is not None + if by_fraction and min_rois is not None: + raise SystemExit( + "K as a fraction and K as an absolute count are two different " + "settings; pass one. A fraction resolves per recording, a count does " + "not, and a run that accepted both would depend on which won.") + floors = () + if not by_fraction: + if min_rois_floor is not None: + raise SystemExit( + "--k-floor applies to --k-percent. With absolute K values the " + "floor is either already in them or is a second opinion.") + floors = tuple(sorted(set(int(k) for k in (min_rois or DEFAULT_MIN_ROIS)))) + if any(k < 2 for k in floors): + raise SystemExit( + f"K must be at least 2 to be a coactivity floor: {floors}") + else: + fracs = tuple(sorted(set(float(f) for f in min_rois_frac))) + if any(not (0.0 < f <= 1.0) for f in fracs): + raise SystemExit( + f"K fractions must be in (0, 1]: {fracs}. Ten percent goes in as " + f"0.10, not 10.") + k_kw = ({"min_rois_frac": fracs} if by_fraction else {"min_rois": floors}) + if by_fraction and min_rois_floor is not None: + k_kw["min_rois_floor"] = int(min_rois_floor) is_folder = (store / "slices.csv").is_file() or (store / "regions.csv").is_file() if is_folder: @@ -227,7 +255,7 @@ def assess_store(store: Path, *, stream: str | None, n_surrogates: int, try: res = assess_coactivity(s, stream=want, window=win, n_surrogates=n_surrogates, - min_rois=floors) + **k_kw) except Exception as e: # noqa: BLE001 skipped["too_short"] += 1 print(f" ~ {f.name}: {type(e).__name__}: {e}", file=sys.stderr) @@ -261,6 +289,16 @@ def assess_store(store: Path, *, stream: str | None, n_surrogates: int, raw_window_sec=float(r.end_sec - r.start_sec), used_analysis_window=bool(getattr(r, "has_analysis_window", False)), K=int(a.min_rois), + # How this recording's K was arrived at, carried per row. On a + # folder of mixed field sizes one setting produces several counts, + # and where a floor binds it produces them under a second rule — + # so a number pooled over these rows cannot say which K made it + # unless each row does. + K_percent=(None if a.min_rois_frac is None + else round(100.0 * a.min_rois_frac, 6)), + K_floor=(None if a.min_rois_floor is None + else int(a.min_rois_floor)), + K_floor_bound=bool(a.min_rois_floor_bound), part_n_obs=float(a.part_n_obs), jit_obs=float(a.jit_obs), jit_null=float(a.jit_null), jit_excess=float(a.jit_excess), jit_defined=bool(a.jit_defined), @@ -410,6 +448,22 @@ def main(argv=None) -> int: "scanning it here would report only the tail. Still a " "scan and still not a choice — a human picks K, and " "`derive_spec --k` is where that happens.") + p.add_argument("--k-percent", default=None, + help="K as PERCENTAGES of each recording's ROI population — " + "e.g. '10' or '5,10,15'. This is the space K is set in " + "(GLOSSARY: K is a percentage a person sets during " + "MAHICE); the absolute count follows each field size on " + "its own, which is what makes one setting fair across a " + "folder running 10 to 61 ROIs. Mutually exclusive with " + "--k.") + p.add_argument("--k-floor", type=int, default=None, metavar="N", + help="an absolute floor under --k-percent, applied per " + "recording after the percentage meets that recording's " + "ROI count. `k_from_fraction` clamps only at 1 and says " + "the clamp belongs to the caller — this is it. Every row " + "records whether the floor BOUND there, because a floor " + "that binds on part of a corpus means two K rules " + "produced the population.") p.add_argument("--out-name", default="assessment_real.json", help="the file to write inside --out. Defaults to the name " "every downstream tool reads, which is why assessing " @@ -419,12 +473,26 @@ def main(argv=None) -> int: folder = _dataset_arg.get(a, want="any") _refuse_to_overwrite_ours(folder, a.out_name) + fracs = None + if a.k_percent is not None: + if a.k is not None: + p.error("--k-percent and --k are two ways of saying K; pass one.") + try: + fracs = tuple(float(x) / 100.0 + for x in a.k_percent.split(",") if x.strip()) + except ValueError: + p.error(f"--k-percent takes percentages, comma-separated — got " + f"{a.k_percent!r}. Ten percent is '10', not '0.10'.") + if not fracs: + p.error("--k-percent was empty") + a.out.mkdir(parents=True, exist_ok=True) res = assess_store(folder, stream=a.stream, n_surrogates=a.n_surrogates, limit=a.limit, assemblies=a.assemblies, assembly_surrogates=a.assembly_surrogates, - min_rois=a.k) + min_rois=a.k, min_rois_frac=fracs, + min_rois_floor=a.k_floor) f = a.out / a.out_name f.write_text(json.dumps(res, indent=1, sort_keys=True)) diff --git a/tools/derive_spec.py b/tools/derive_spec.py index 612c32bd..8147e4d7 100644 --- a/tools/derive_spec.py +++ b/tools/derive_spec.py @@ -41,33 +41,81 @@ def build(assessment: dict, k: int, *, events_per_level: int = 5, n_levels: int = 3, annotations=None, k_estimate=None, - session=None, cross_check=None) -> dict: + session=None, cross_check=None, k_percent=None, + k_floor=None) -> dict: from bugarach.adapt import generator_params from bugarach.assess import Assessment from bugarach.bench import (BENCH_RECORDING, MEASURED_BURST_BINS, MEASURED_BURST_SHAPE, MEASURED_RATE_SHAPE) + # A K SET AS A PERCENTAGE IS ONE SETTING THAT RESOLVES TO SEVERAL COUNTS, and + # on a folder of mixed field sizes those counts are the point rather than an + # inconvenience. The absolute-K path below selects one column of the scan, + # which is correct when the assessment holds every recording at every K. An + # assessment produced at a single percentage does not: each recording appears + # once, at its own count, so selecting a column would silently drop most of + # the corpus — 10% floored at 3 lands on K=3,4,5,6 across this cohort and the + # median column alone holds well under half of it. + # + # So this path aggregates ACROSS rows, each already at the K the percentage + # resolved to there. That is what "one percentage, fair across the folder" + # means (GLOSSARY: K is a percentage a person sets during MAHICE); collapsing + # to a median count and filtering to it would make the setting a count again. by_k = assessment["by_k"] - if str(k) not in by_k: - # The likeliest way here is a percentage set in MAHICE that resolved to a - # count the assessment never measured. Nothing can be interpolated — the - # measures are computed AT a K, not sampled from a curve — so the answer - # is to re-assess at the percentage the person set, not to pick a nearby - # column. - extra = "" if session is None else ( - f" It came from {100.0 * session.k_percent:.3g}% of ROIs, set by " - f"{session.annotator} during MAHICE. Re-run `bugarach assess " - f"--k-percent {100.0 * session.k_percent:.3g}` on this folder: every " - f"measure is computed at a K rather than read off a curve, so there " - f"is nothing here to interpolate.") - raise SystemExit(f"K={k} not in the scan ({sorted(by_k, key=int)}).{extra}") - v = by_k[str(k)] - - def med(field): - m = v[field]["median"] - return float("nan") if m is None else float(m) - - rows = [r for r in assessment["rows"] if r["K"] == k] + if k_percent is not None: + rows = list(assessment["rows"]) + if not rows: + raise SystemExit("the assessment holds no rows") + seen_pct = {r.get("K_percent") for r in rows} + seen_floor = {r.get("K_floor") for r in rows} + if seen_pct != {k_percent} or seen_floor != {k_floor}: + raise SystemExit( + f"this assessment was not produced at --k-percent {k_percent} " + f"--k-floor {k_floor}: its rows carry percent {sorted(seen_pct, key=str)} " + f"and floor {sorted(seen_floor, key=str)}. Re-run " + f"`tools/assess_archive.py --k-percent {k_percent}" + + (f" --k-floor {k_floor}" if k_floor is not None else "") + + "` rather than deriving at a setting the measurement was not " + "made at — every measure is computed AT a K, not sampled from " + "a curve.") + + def med(field): + vals = [float(r[field]) for r in rows + if r.get(field) is not None and np.isfinite(float(r[field]))] + return float(np.median(vals)) if vals else float("nan") + + n_jit_defined = sum(1 for r in rows if r.get("jit_defined")) + k_resolved = sorted({int(r["K"]) for r in rows}) + # One count still has to go into the Assessment the generator is built + # from, because `generator_params` takes a participation floor and not a + # distribution of them. The median of the resolved counts is that number, + # and the spread is written into the notes rather than dropped: a reader + # who sees only `k_chosen` would take it for a setting somebody made. + k = int(np.median([int(r["K"]) for r in rows])) + else: + if str(k) not in by_k: + # The likeliest way here is a percentage set in MAHICE that resolved + # to a count the assessment never measured. Nothing can be + # interpolated — the measures are computed AT a K, not sampled from a + # curve — so the answer is to re-assess at the percentage the person + # set, not to pick a nearby column. + extra = "" if session is None else ( + f" It came from {100.0 * session.k_percent:.3g}% of ROIs, set by " + f"{session.annotator} during MAHICE. Re-run `bugarach assess " + f"--k-percent {100.0 * session.k_percent:.3g}` on this folder: " + f"every measure is computed at a K rather than read off a curve, " + f"so there is nothing here to interpolate.") + raise SystemExit( + f"K={k} not in the scan ({sorted(by_k, key=int)}).{extra}") + v = by_k[str(k)] + + def med(field): + m = v[field]["median"] + return float("nan") if m is None else float(m) + + rows = [r for r in assessment["rows"] if r["K"] == k] + n_jit_defined = v["n_jit_defined"] + k_resolved = [int(k)] n_roi = int(round(assessment["n_roi"]["median"])) win = float(np.median([r["window_sec"] for r in rows])) # Per-ROI rate, derived from the POPULATION event rate over the window @@ -145,7 +193,24 @@ def med(field): # chosen off the scan is a convention the spec inherits; a K estimated from # labelled calls is a measurement, and the difference is the whole point of # the annotation step. - if session is not None: + if k_percent is not None: + n_bound = sum(1 for r in rows if r.get("K_floor_bound")) + spread = ", ".join(f"K={kk} on {sum(1 for r in rows if int(r['K']) == kk)}" + for kk in k_resolved) + notes_pre.insert(0, ( + f"K = {k_percent:g}% of each recording's ROI population" + + (f", floored at {k_floor}" if k_floor is not None else "") + + f". Across the {len(rows)} recordings assessed that resolved to " + f"{spread}, and every measure above is a median ACROSS those " + f"recordings at each one's own count — not one column of the scan. " + f"`k_chosen` below is the median of the resolved counts and is a " + f"summary, not the setting." + + ("" if not n_bound else + f" ⚠ THE FLOOR BOUND ON {n_bound} OF {len(rows)} RECORDINGS: on " + f"those, K came from the floor and not from the percentage, so " + f"this population was governed by two rules at once. They are the " + f"small fields, where {k_percent:g}% rounds below {k_floor}."))) + elif session is not None: notes_pre.insert(0, ( f"K = {100.0 * session.k_percent:.3g}% of each recording's ROI " f"population, SET BY {session.annotator} during MAHICE on " @@ -169,7 +234,7 @@ def med(field): part_n_obs=part_n, jit_obs=jit_o, jit_null=med("jit_null"), jit_excess=med("jit_excess"), - jit_defined=v["n_jit_defined"] > 0, + jit_defined=n_jit_defined > 0, span_med=span, clusters_permin=clusters, coact_excess=med("coact_excess"), @@ -262,8 +327,24 @@ def med(field): # labels happen to separate", because only the first is how K is chosen. "k_source": ("mahice" if session is not None else "estimated_from_labels" if k_estimate is not None + else "percentage_unreviewed" if k_percent is not None else "given"), - "k_percent": (None if session is None else float(session.k_percent)), + "k_percent": (float(session.k_percent) if session is not None + else k_percent / 100.0 if k_percent is not None + else None), + # The percentage route WITHOUT a MAHICE record. `k_source` says + # `percentage_unreviewed` and not `mahice` on purpose: the setting has the + # shape a person would have set, and no person set it. A consumer must be + # able to tell those apart — that is what `k_source` is for. + "k_percentage_rule": (None if k_percent is None else { + "k_percent": float(k_percent), + "k_floor": (None if k_floor is None else int(k_floor)), + "resolved_counts": {str(kk): sum(1 for r in rows if int(r["K"]) == kk) + for kk in k_resolved}, + "n_floor_bound": sum(1 for r in rows if r.get("K_floor_bound")), + "n_recordings": len(rows), + "aggregation": "median across recordings, each at its own resolved K", + }), "k_mahice": (None if session is None else { "k_percent": float(session.k_percent), "k_absolute": dict(session.k_absolute), @@ -328,6 +409,18 @@ def main(argv=None) -> int: "PERSON set, as a percentage of each recording's ROI " "population. THIS IS WHERE K COMES FROM; the labels are " "cross-checked against it and never override it") + p.add_argument("--k-percent", type=float, default=None, metavar="PCT", + help="K as a PERCENTAGE of each recording's ROI population, " + "matching the setting the assessment was produced at. " + "Aggregates across every recording at its own resolved " + "count instead of selecting one column of the scan — " + "which is what a percentage means on a folder of unequal " + "field sizes. Sets k_source=percentage_unreviewed: it has " + "the shape of a K a person would set and no person set " + "it, and a consumer must be able to tell those apart") + p.add_argument("--k-floor", type=int, default=None, metavar="N", + help="the absolute floor --k-percent was assessed with. Must " + "match the assessment or the run is refused") p.add_argument("--k", type=int, default=None, help="an absolute K, for an assessment or a folder that " "predates mahice.json. Exactly one of --session / --k / " @@ -367,17 +460,22 @@ def main(argv=None) -> int: # for an assessment that predates the record. `--k-from-annotations` asks the # arithmetic and is not how K is chosen. Allowing two would leave the answer # depending on which won; allowing none is the state this step exists to end. - routes = [bool(a.session), a.k is not None, a.k_from_annotations] + routes = [bool(a.session), a.k is not None, a.k_from_annotations, + a.k_percent is not None] if sum(routes) == 0: p.error("no K. K is set by a person during MAHICE — pass --session " "mahice.json. For an assessment that predates that record, " - "--k ; to see what the labels alone would say, " + "--k ; for a percentage nobody signed off on, --k-percent " + "; to see what the labels alone would say, " "--k-from-annotations.") if sum(routes) > 1: - p.error("--session, --k and --k-from-annotations are three routes to " - "one K; pass one. --session is the normal route: it carries the " - "K a person set, as a percentage, and the other two would " - "silently disagree with it.") + p.error("--session, --k, --k-percent and --k-from-annotations are four " + "routes to one K; pass one. --session is the normal route: it " + "carries the K a person set, as a percentage, and the others " + "would silently disagree with it.") + if a.k_floor is not None and a.k_percent is None: + p.error("--k-floor describes the setting --k-percent was assessed at; " + "pass them together.") if a.k_from_annotations and a.annotations is None: p.error("--k-from-annotations needs --annotations: K is estimated FROM " "the verdicts, so there is nothing to estimate it from.") @@ -435,7 +533,7 @@ def main(argv=None) -> int: spec = build(json.loads(a.assessment.read_text()), k, annotations=verdicts, k_estimate=est, session=session, - cross_check=check) + cross_check=check, k_percent=a.k_percent, k_floor=a.k_floor) a.out.mkdir(parents=True, exist_ok=True) f = a.out / "generator_spec.json" f.write_text(json.dumps(spec, indent=1, sort_keys=True)) diff --git a/tools/make_before_after_figure.py b/tools/make_before_after_figure.py index 28e017df..9e775f83 100644 --- a/tools/make_before_after_figure.py +++ b/tools/make_before_after_figure.py @@ -12,6 +12,24 @@ Panels are lettered; the two panels of a row share a y-axis so the streams can be read against each other; each line ends in its recording's id. +FACETED BY EXPERIMENTAL GROUP, AND ON THIS CORPUS THAT IS NOT A PREFERENCE. +FOUNDATIONS §9: effects run in OPPOSITE DIRECTIONS by group — ORX up, male +unchanged, diestrus down under TTX — so a panel pooling them hides a sign change +and is not admissible on its own. ``--facet group`` puts one group per column and +is the default whenever the folder's `slices.csv` carries `group_id`; each panel is +then a standard fireflies before/after over that group's recordings. + +ONE PAGE PER DETECTOR (``--per-detector``). Twelve detectors x two streams x four +groups does not fit one page legibly, and stacking them invites reading down a +column as though it were a ranking — which is the thing `performance_table.md` +declines to do. Each page carries one detector's claim, its own y-scale, and the +same facet grid, so pages can be flipped against each other. + +Y-AXES: shared ACROSS the group facets of one stream, never across streams and +never across detectors. Groups are the comparison the facets exist to allow, so +they need one scale; fast and slow are different measurements, and two detectors' +rates are two different instruments' units. + WHAT IT IS AND IS NOT. It is *output*: the coordinated-event table read back per recording and period, so a reader can judge each detector quickly (`docs/pipeline.md`, Output). It is **not** a treatment-effect analysis — FOUNDATIONS @@ -90,9 +108,28 @@ def counts(*detections: Path): return n, order +def groups_of(folder: Path) -> dict[str, str]: + """slice_id -> group_id, from the folder's own `slices.csv`. + + The producer's column, carried unchanged. Recordings whose folder does not + declare a group come back under ``""`` and are drawn in one unlabelled facet + rather than being dropped — a missing group is a fact about the folder, and + silently discarding those recordings would shrink an n nobody was told about. + """ + out: dict[str, str] = {} + f = Path(folder) / "slices.csv" + if not f.is_file(): + return out + with f.open(newline="") as fh: + for r in csv.DictReader(fh): + out[str(r.get("slice_id", "")).strip()] = str(r.get("group_id") or "").strip() + return out + + def rates(folder: Path, *detections: Path, baseline: str, treatment: str): wins = windows(folder) n, ran = counts(*detections) + grp = groups_of(folder) slices = sorted({sid for sid, _ in wins}) streams = sorted({s for _, s, _, _ in n}) or ["fast", "slow"] # The glossary's order for the six, then arrival for anything else — a learned @@ -100,7 +137,9 @@ def rates(folder: Path, *detections: Path, baseline: str, treatment: str): # because a second file was passed would make two runs incomparable by eye. detectors = ([d for d in DETECTORS if d in ran] + [d for d in ran if d not in DETECTORS]) or list(DETECTORS) - rows = [] # (detector, stream, slice, baseline_rate, treatment_rate, baseline_calls, treatment_calls) + # (detector, stream, slice, group, baseline_rate, treatment_rate, + # baseline_calls, treatment_calls) + rows = [] missing = [] for sid in slices: b = next((k for k, v in wins.items() if k[0] == sid and v[0] == baseline), None) @@ -111,7 +150,8 @@ def rates(folder: Path, *detections: Path, baseline: str, treatment: str): for d in detectors: for s in streams: nb, nt = n.get((sid, s, d, b[1]), 0), n.get((sid, s, d, t[1]), 0) - rows.append((d, s, sid, nb / wins[b][1], nt / wins[t][1], nb, nt)) + rows.append((d, s, sid, grp.get(sid, ""), + nb / wins[b][1], nt / wins[t][1], nb, nt)) return rows, detectors, streams, missing @@ -126,12 +166,83 @@ def recording_inks(rows): for i, sid in enumerate(sorted({r[2] for r in rows}))} +#: One ink per experimental group. Okabe–Ito again, and the ink is the FACET's +#: identity rather than any recording's: with 29–38 recordings a per-recording key +#: is unreadable, so identity moves to the sidecar CSV, which carries every +#: recording's id, group and both counts. +GROUP_INKS = ("#0072B2", "#D55E00", "#009E73", "#CC79A7", "#E69F00", "#56B4E9", "#000000") + + +def group_inks(groups): + return {g: GROUP_INKS[i % len(GROUP_INKS)] for i, g in enumerate(groups)} + + +def facet_groups(rows) -> list[str]: + """The groups present, named ones first and the unlabelled facet last.""" + gs = sorted({r[3] for r in rows}) + return [g for g in gs if g] + ([""] if "" in gs else []) + + def dot_size(calls: int) -> float: """Dot area grows with the number of calls behind the rate, so a one-call endpoint is visibly one call. Keyed in the header.""" return 4.0 + 2.6 * math.sqrt(calls) +def build_faceted(rows, detector, streams, groups, baseline, treatment, *, + width=330, height=300): + """One detector's page: rows are streams, columns are experimental groups. + + **Y is shared across the group facets of one stream and never beyond it.** + Comparing groups is the entire reason the facets exist, and facets on + different scales cannot be compared by eye — so the row shares one range. It + stops at the row: fast and slow are different measurements (GLOSSARY), and + another detector's rate is another instrument's unit. + + Nothing is drawn over the data. Each panel is a plain before/after — paired + points joined per recording — and its identity (group, stream, n) lives in the + y-axis label, which is where this project puts identity. + """ + import holoviews as hv + hv.extension("bokeh") + + inks = group_inks(groups) + letters = iter("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + panels = [] + for s in streams: + row_rows = [r for r in rows if r[0] == detector and r[1] == s] + # One range for the whole stream row, computed before any panel is drawn. + ymax = max([max(r[4], r[5]) for r in row_rows] or [0.0]) * 1.15 or 1.0 + for g in groups: + sub = [r for r in row_rows if r[3] == g] + letter = next(letters) + ink = inks[g] + els = [] + for _, _, sid, _, b, t, nb, nt in sub: + els.append(hv.Curve([(baseline, b), (treatment, t)], + kdims=["period"], vdims=[f"rate_{s}"] + ).opts(color=ink, line_width=1.4, alpha=0.7)) + for x, v, nn in ((baseline, b, nb), (treatment, t, nt)): + # Hollow means NO calls at all. Filled-but-tiny reads as one + # call, and "none" and "one" are the difference between a + # detector that was silent and one that fired once. + els.append(hv.Scatter([(x, v)], kdims=["period"], + vdims=[f"rate_{s}"] + ).opts(color=("white" if nn == 0 else ink), + line_color=ink, line_width=1.3, + size=dot_size(nn), alpha=0.9)) + ov = (hv.Overlay(els) if els + else hv.Curve([], kdims=["period"], vdims=[f"rate_{s}"])) + ov = ov.opts(width=width, height=height, toolbar=None, + show_legend=False, + ylabel=f"{letter} · {g or 'no group'} · {s} · " + f"{len(sub)} rec · events/min", + xlabel="period", padding=(0.25, 0.1), + ylim=(-0.05 * ymax, ymax)) + panels.append(ov) + return hv.Layout(panels).cols(len(groups)).opts(shared_axes=False, toolbar=None) + + def build(rows, detectors, streams, baseline, treatment, *, width=400, height=300): """Panel height is set by the y-axis LABEL, not by the data. @@ -150,12 +261,12 @@ def build(rows, detectors, streams, baseline, treatment, *, width=400, height=30 panels = [] for d in detectors: row_rows = [r for r in rows if r[0] == d] - ymax = max([max(r[3], r[4]) for r in row_rows] or [0.0]) * 1.15 or 1.0 + ymax = max([max(r[4], r[5]) for r in row_rows] or [0.0]) * 1.15 or 1.0 for s in streams: sub = [r for r in row_rows if r[1] == s] letter = next(letters) els = [] - for _, _, sid, b, t, nb, nt in sub: + for _, _, sid, _, b, t, nb, nt in sub: els.append(hv.Curve([(baseline, b), (treatment, t)], kdims=["period"], vdims=[f"rate_{d}_{s}"]).opts(color=inks[sid], line_width=1.6, alpha=0.85)) for x, v, n in ((baseline, b, nb), (treatment, t, nt)): @@ -186,10 +297,32 @@ def main(argv=None) -> int: ap.add_argument("--out", default=None, help="destination directory (default: the darkroom)") ap.add_argument("--also", default=None, help="write a second copy here") ap.add_argument("--stem", default="before_after_coordinated_events") + ap.add_argument("--per-detector", action="store_true", + help="write ONE PAGE PER DETECTOR, faceted by experimental " + "group (columns) and stream (rows), instead of one page " + "stacking every detector. Twelve detectors x two streams " + "x four groups does not fit one page legibly, and " + "stacking them invites reading down a column as a " + "ranking — which is what performance_table.md declines " + "to do") + ap.add_argument("--facet", choices=("group", "none"), default="group", + help="facet each page by the producer's group_id (default) " + "or not at all. FOUNDATIONS section 9: effects run in " + "OPPOSITE DIRECTIONS by group on this preparation, so a " + "pooled panel can hide a sign change and is not " + "admissible on its own. Only meaningful with " + "--per-detector") a = ap.parse_args(argv) rows, detectors, streams, missing = rates(a.folder, *a.detections, baseline=a.baseline, treatment=a.treatment) + # A RECORDING THE DETECTIONS FILE NEVER MENTIONS IS DRAWN AT ZERO, and at + # zero it is indistinguishable from a recording that was scored and found + # nothing. Those are different facts — one is a detector's answer, the other + # is a run that did not cover this recording (a `--limit`, a crash, the wrong + # file). Nothing downstream can tell them apart, so the count is put on the + # page rather than left for a reader to not notice. + silent = sorted({r[2] for r in rows} - {sid for sid, _, _, _ in counts(*a.detections)[0]}) if not rows: print(f"nothing to draw: no recording has both a {a.baseline!r} and a {a.treatment!r} period", file=sys.stderr) @@ -232,22 +365,85 @@ def main(argv=None) -> int: f"
{a.folder.name} · " + "  ·  ".join(f"{d.parent.name}/{d.name}" for d in a.detections) + "
") - page = pn.Column(header, pn.pane.HoloViews(build(rows, detectors, streams, a.baseline, a.treatment))) - _write(page, dest, a.stem, png=True) + if a.per_detector: + groups = facet_groups(rows) if a.facet == "group" else [""] + if a.facet != "group": + # One facet holding everything: the page shape stays identical so the + # two forms are comparable, and the pooled panel is what the caller + # asked for rather than something inferred. + rows = [(d, s, sid, "", b, t, nb, nt) for d, s, sid, _, b, t, nb, nt in rows] + ginks = group_inks(groups) + gkey = "   ".join( + f"" + f"{g or 'no group'}" + for g, ink in ginks.items()) + for d in detectors: + name = DETECTOR_NAME.get(d, d) + head = pn.pane.HTML( + f"
" + f"{name} · coordinated events per minute · " + f"{a.baseline} → {a.treatment}" + f"  —  one line per recording; columns are experimental " + f"groups, rows are streams. The y-range is shared across the " + f"group facets of a stream and nowhere else: comparing groups " + f"is what the facets are for, while fast and slow are different " + f"measurements and another detector's rate is another " + f"instrument's unit." + f"
groups: {gkey}
" + f"
dot area grows with the " + f"calls behind the rate: " + + "  ".join( + f" {n}" for n in (1, 10, 50)) + + " calls  ·  hollow = no calls at all in that period, " + "which is not the same as a small rate
" + f"
Descriptive output, not " + f"an analysis: calls inside each period divided by the length of " + f"the window the folder was scored on. No statistic, no ground " + f"truth, no verdict — there IS no ground truth on a real folder, " + f"and a mark on a real recording is a claim. Treatment effects " + f"are analysed by fireflies, not here; group facets are shown " + f"separately because effects run in opposite directions by group " + f"on this preparation, so a pooled panel can hide a sign change. " + f"{'Skipped, lacking one of the two periods: ' + ', '.join(missing) if missing else ''}
" + + (f"
⚠ {len(silent)} of " + f"{len(silent) + len({r[2] for r in rows}) - len(silent)} " + f"recordings contribute no call from ANY detector in this " + f"file and are drawn at zero: {', '.join(silent)}. At zero " + f"that is indistinguishable from a detector that ran and " + f"found nothing — check this is a complete run.
" + if silent else "") + + f"
{a.folder.name} · " + + "  ·  ".join(f"{p.parent.name}/{p.name}" for p in a.detections) + + "
") + page = pn.Column(head, pn.pane.HoloViews( + build_faceted(rows, d, streams, groups, a.baseline, a.treatment))) + # The capture width has to follow the facet count. At the shared + # default a four-group page lost its fourth column with no error and + # a PNG that looked finished; 90 px per column covers the rotated + # y-label and the tick text beside each panel. + _write(page, dest, f"{a.stem}__{d}", png=True, + viewport_width=max(1120, len(groups) * (330 + 90) + 40)) + else: + page = pn.Column(header, pn.pane.HoloViews(build(rows, detectors, streams, a.baseline, a.treatment))) + _write(page, dest, a.stem, png=True) table = dest / f"{a.stem}.csv" with table.open("w", newline="") as fh: w = csv.writer(fh) - w.writerow(["detector", "detector_name", "stream", "slice_id", + w.writerow(["detector", "detector_name", "stream", "slice_id", "group_id", f"{a.baseline}_calls", f"{a.baseline}_events_per_min", f"{a.treatment}_calls", f"{a.treatment}_events_per_min"]) - for d, s, sid, b, t, nb, nt in rows: - w.writerow([d, DETECTOR_NAME.get(d, d), s, sid, nb, f"{b:.4f}", nt, f"{t:.4f}"]) + for d, s, sid, g, b, t, nb, nt in rows: + w.writerow([d, DETECTOR_NAME.get(d, d), s, sid, g, + nb, f"{b:.4f}", nt, f"{t:.4f}"]) print(f"wrote {table}") if a.also: also = Path(a.also).expanduser() also.mkdir(parents=True, exist_ok=True) - for f in dest.glob(f"{a.stem}.*"): + for f in sorted(dest.glob(f"{a.stem}*")): with tempfile.TemporaryDirectory() as td: tmp = Path(td) / f.name tmp.write_bytes(f.read_bytes()) diff --git a/tools/make_detector_table.py b/tools/make_detector_table.py index b827dd6e..4a9a70dd 100644 --- a/tools/make_detector_table.py +++ b/tools/make_detector_table.py @@ -163,8 +163,13 @@ def observed(run: Path, folder: Path, *, baseline: str, treatment: str, pad: flo mine = [r for r in rows if r[0] == det] # Median OVER RECORDINGS of each recording's own rate, both streams pooled; # a mean would be carried by the two busiest recordings in a cohort of six. - b = [r[3] * 60.0 for r in mine] - t = [r[4] * 60.0 for r in mine] + # Indices 4 and 5, not 3 and 4: `rates()` grew a `group` field at index 3 + # on 2026-09-09 for the group-faceted figure. Unpacking by position across + # a module boundary is what made that a silent multiply-a-string rather + # than a wrong number — which is the good outcome, and the reason to name + # the fields here rather than count them. + b = [r[4] * 60.0 for r in mine] + t = [r[5] * 60.0 for r in mine] d = per.get(det, {}) chance = d.get("chance", float("nan")) pct_lone = (100.0 * d["lone"] / d["n"]) if d.get("n") else float("nan") @@ -174,7 +179,7 @@ def observed(run: Path, folder: Path, *, baseline: str, treatment: str, pad: flo # reader means by "how many calls". The two rate columns beside it are the # named periods only, so the three do not add up and must not be made to. calls=d.get("n", 0), - calls_in_the_two_periods=sum(r[5] + r[6] for r in mine), + calls_in_the_two_periods=sum(r[6] + r[7] for r in mine), baseline_per_min=float(np.median(b)) if b else float("nan"), treatment_per_min=float(np.median(t)) if t else float("nan"), lone=d.get("lone", 0), diff --git a/tools/make_generator_figures.py b/tools/make_generator_figures.py index ae86bb1d..c2d8bb58 100644 --- a/tools/make_generator_figures.py +++ b/tools/make_generator_figures.py @@ -355,9 +355,17 @@ def main(argv=None): return 0 -def _write(page, dest: Path, stem: str, png: bool): +def _write(page, dest: Path, stem: str, png: bool, viewport_width: int = 1120): """Write to a temporary name and move into place — the darkroom README - records 188 MB of hash-named orphans from writing into Dropbox in place.""" + records 188 MB of hash-named orphans from writing into Dropbox in place. + + ``viewport_width`` is the browser width the PNG is captured at, and the + default is unchanged for every existing caller. **A page wider than it loses + its rightmost column silently** — no error, and a PNG that looks finished. A + four-facet page rendered here at the default came out with three facets and + the fourth sliced down the middle, which is only visible if somebody opens + the file. A caller that knows how wide its own layout is should say so. + """ with tempfile.TemporaryDirectory() as td: tmp_html = Path(td) / "page.html" page.save(str(tmp_html)) @@ -366,14 +374,15 @@ def _write(page, dest: Path, stem: str, png: bool): print(f"wrote {html}") if png: shot = dest / f"{stem}.png" - if _render_png(html, shot): + if _render_png(html, shot, viewport_width=viewport_width): print(f" {shot}") else: print(" (PNG skipped — needs playwright chromium)", file=sys.stderr) -def _render_png(html_path: Path, png_path: Path, *, wait_ms: int = 3000) -> bool: +def _render_png(html_path: Path, png_path: Path, *, wait_ms: int = 3000, + viewport_width: int = 1120) -> bool: try: from playwright.sync_api import sync_playwright except ImportError: @@ -381,7 +390,8 @@ def _render_png(html_path: Path, png_path: Path, *, wait_ms: int = 3000) -> bool try: with sync_playwright() as pw: browser = pw.chromium.launch() - page = browser.new_page(viewport={"width": 1120, "height": 1200}, + page = browser.new_page(viewport={"width": int(viewport_width), + "height": 1200}, device_scale_factor=2) page.goto(html_path.resolve().as_uri()) page.wait_for_timeout(wait_ms) @@ -424,7 +434,14 @@ def _render_png(html_path: Path, png_path: Path, *, wait_ms: int = 3000) -> bool f"measured no rendered content ({w}x{h}) — the page " f"did not draw, or every element matched the viewport") clip_h = min(float(h) + 12, 4000.0) - clip_w = min(float(w) + 12, 1120.0) + # Against the VIEWPORT, never a literal. This line read `1120.0` + # — the old viewport as a constant — so widening the viewport + # widened the window and clipped the shot back to 1120 anyway, + # and a four-facet page came out with its fourth facet sliced + # down the middle. Same shape as the height lesson two comments + # up: a pixel constant standing in for the thing it was copied + # from, correct until the thing moves. + clip_w = min(float(w) + 12, float(viewport_width)) # A CLIP TALLER THAN THE VIEWPORT IS SILENTLY CUT TO IT. The # measurement above is correct — it reads the document, not the # window — but `screenshot(clip=...)` without `full_page` can