Skip to content

build(deps): bump mcp from 1.27.0 to 1.28.1 in /clients/python/pymat-mcp - #243

Open
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/uv/clients/python/pymat-mcp/mcp-1.28.1
Open

build(deps): bump mcp from 1.27.0 to 1.28.1 in /clients/python/pymat-mcp#243
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/uv/clients/python/pymat-mcp/mcp-1.28.1

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Jul 16, 2026

Copy link
Copy Markdown
Contributor

Bumps mcp from 1.27.0 to 1.28.1.

Release notes

Sourced from mcp's releases.

v1.28.0

Deprecations

Two API surfaces now emit DeprecationWarning ahead of their removal in v2. Nothing is removed in 1.x, and the warnings fire only when the deprecated API is called - importing the modules stays silent.

  • WebSocket transport - mcp.client.websocket.websocket_client and mcp.server.websocket.websocket_servermodelcontextprotocol/typescript-sdk#1783
  • Experimental tasks API - ClientSession.experimental, Server.experimental, ServerSession.experimental, and the experimental_task_handlers= kwarg on ClientSession. Tasks (SEP-1686) were removed from the MCP specification and are expected to return as a separate MCP extension.

If your test suite runs with filterwarnings = ["error"] and exercises these paths, add a scoped ignore such as ignore:The experimental tasks API is deprecated:DeprecationWarning or ignore:The WebSocket .* transport is deprecated:DeprecationWarning.

See #2828 for full details.

What's Changed

New Contributors

Full Changelog: modelcontextprotocol/python-sdk@v1.27.2...v1.28.0

v1.27.2

What's Changed

Full Changelog: modelcontextprotocol/python-sdk@v1.27.1...v1.27.2

v1.27.1

What's Changed

Full Changelog: modelcontextprotocol/python-sdk@v1.27.0...v1.27.1

Commits
  • 777b8d0 [v1.x] Support TransportSecuritySettings in the WebSocket server transport (#...
  • 4720467 [v1.x] Set Development Status classifier to Production/Stable (#2976)
  • 6df3d73 [v1.x] Buffer per-request StreamableHTTP streams; store priming event before ...
  • 32d3290 [v1.x] Pass a list to parametrize in test_docs_examples (pytest 9.1.0 compat)...
  • 0dca751 [v1.x] Deflake the child process cleanup tests (#2839)
  • 52258a9 [v1.x] Add a v2 status banner to the README (#2835)
  • b8f4917 [v1.x] Deprecate the WebSocket transport and the experimental tasks entry poi...
  • 2309e5e fix: omit null optional fields from task result payloads (#2809)
  • 494eb11 [v1.x] Support Python 3.14 (#2769)
  • 6213787 [v1.x] Scope experimental tasks to the session that created them (#2720)
  • Additional commits viewable in compare view

Dependabot compatibility score

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

  • @dependabot rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    You can disable automated security fix PRs for this repo from the Security Alerts page.

Bumps [mcp](https://github.com/modelcontextprotocol/python-sdk) from 1.27.0 to 1.28.1.
- [Release notes](https://github.com/modelcontextprotocol/python-sdk/releases)
- [Changelog](https://github.com/modelcontextprotocol/python-sdk/blob/main/RELEASE.md)
- [Commits](modelcontextprotocol/python-sdk@v1.27.0...v1.28.1)

---
updated-dependencies:
- dependency-name: mcp
  dependency-version: 1.28.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file python:uv Pull requests that update python:uv code labels Jul 16, 2026
gerchowl added a commit that referenced this pull request Aug 18, 2026
…ces, finishes

The README is generated from tests/test_readme_examples.py, so an addition to
the public API that is not exercised there is invisible to users even though it
ships. This branch added three user-facing capabilities with no README
coverage.

Adds three examples, each of which is a real executable test:

- wavelength-dependent optics, using BGO to show why it matters — the scalar
  refractive index is fitted near the emission peak, so n at the blue end of
  the band is understated, and the clamp-not-extrapolate contract is visible
  through `range_nm`
- declared absences, showing the distinction the mechanism exists for: LYSO's
  emission spectrum is absent-with-a-reason, while a property nobody has
  spoken about stays silent
- the measured surface-finish catalogue, showing the air-gap versus
  optical-contact distinction on the same reflector

27 -> 30 examples; README regenerated and matching.

Refs: #243
gerchowl added a commit that referenced this pull request Aug 18, 2026
… uncited value

Two CI failures, both real.

TYPOS. The `typos` hook rewrites three things this branch introduced, and all
three rewrites are wrong:

  tio  -> to     `tio` is TiO2, titanium dioxide reflective paint. It appears in
                 the Geant4 RealSurface finish names (polishedtioair,
                 etchedtioair, groundtioair) and in the catalogue keys derived
                 from them. The rewrite would silently rename three measured
                 surfaces — and note it caught the KEYS while leaving the
                 verbatim G4 enum strings alone, so the two would have drifted.
  Hass -> Hash   G. Hass, "Filmed surfaces for reflecting optics", JOSA 45, 945
                 (1955). A surname, cited for evaporated-aluminium reflectance.
  mis  -> miss   "mis-parsed" as a prefix, in a Rust doc comment.

TEST. `test_apply_scintillator_to_shape` pinned prelude420's light yield at
34000. That was the uncited round-up this branch corrected to the Luxium data
sheet's 33200, so the test was asserting the defect. Updated with the reason
inline.

Worth recording WHY that one reached CI rather than being caught locally: it is
gated behind `pytest.importorskip("build123d")`, and build123d pins
`python_version < '3.13'` while this machine is on 3.13. The local suite skips
64 tests where CI skips 54, so ten tests never ran here. I swept the
build123d-gated files for any other assertion touching data this branch
changed, and checked the corpus for tests asserting a root material's grade or
vendor IS None — the case my loader fix inverts. Nothing else is affected.

Refs: #243
gerchowl added a commit that referenced this pull request Aug 18, 2026
…ption

BLOCKER from pre-merge review, verified and reproduced identically in both
languages:

  k=0.1, s=572 /cm, d=0.2 mm, Rg=0.9  ->  R=95.13  T=7.97  A=-3.09
  k=0.001, s=1 /cm, d=1 cm,   Rg=0.5  ->  R=66.58  T=49.94 A=-16.52

The docstring claimed the tuple sums to 100 "by construction" and that A is
"the only true loss". The sum held — because A was DEFINED as 100-R-T — but the
interpretation did not. With a reflective backing, Kubelka-Munk's R is the
reflectance of the COMPOSITE, layer plus backing, including light that crossed
the layer, bounced, and came back. T remains the layer's own transmittance.
They are not two parts of one photon budget, so the remainder stops meaning
"absorbed" and goes negative. The k=0 branch additionally ignored the parameter
outright, so a non-absorbing layer on a perfect mirror returned sd/(1+sd)
rather than R -> 1.

REMOVED rather than repaired. Getting it right means first deciding what a
three-way split even means when R and T are measured against different
references, and that is a physics question with no consumer waiting on it — the
one downstream user passes the default. Shipping a knob whose documented
meaning I got wrong is worse than not shipping it. It can return when there is
a physical definition and someone who needs it.

At Rg=0 the numbers are unchanged and correct: a transmitted photon is gone,
which is the right model for an inter-crystal septum and every use this branch
has.

Why the suite missed it, which is the part worth keeping: the single test
touching the parameter asserted `bright > black` — an ORDERING property. That
passes throughout the defect. A conservation claim has to be tested as a
conservation claim, and the replacement sweeps 24 wavelength/thickness
combinations asserting A >= 0 and R+T+A == 100. Removal is pinned too, so
re-adding the parameter is a deliberate act rather than a merge artefact.

Also from review:
- Rust `Absent::from_toml` accepted any `reason` string while Python enforces a
  closed vocabulary. Data arriving through `MaterialDb::open` on a
  hand-authored directory never passed the Python gate, so it now validates
  against `ABSENT_REASONS` there too rather than inheriting a weaker guarantee
  from whichever path was taken.
- `obliquity_factor`'s cap at 40 fired silently; it now logs at debug when it
  clamps, so "computed" and "capped" are distinguishable.

Refs: #243
gerchowl added a commit that referenced this pull request Aug 18, 2026
…bering

CI's `pymarkdown` hook auto-fixes; the job fails when it changes a file. Two
things in the strata response doc:

- two indented code blocks converted to fenced (MD046)
- an ordered list renumbered 4/5/6 -> 1/2/3 (MD029), which is also the more
  correct reading: the prose introduces it as "three found while implementing",
  so it is a new list rather than a continuation. Checked that nothing in the
  document cross-references those numbers.

Ran through the same pinned version (0.9.23) rather than hand-patching to match
a diff, and swept every markdown file the hook covers to confirm nothing else
drifts. Verified idempotent.

Note for anyone else on NixOS: pymarkdown's `pyjson5` dependency needs
libstdc++ on the loader path, so it fails to import out of the box.
`LD_LIBRARY_PATH=$(nix eval --raw nixpkgs#stdenv.cc.cc.lib)/lib` makes it run.

Refs: #243
gerchowl added a commit that referenced this pull request Aug 18, 2026
… absences, rs-materials 0.3.0 (#244)

* fix(vis): make mat_vis_client optional — pymat must import without it

`pymat/__init__.py` does `from . import factories, registry, vis` eagerly, and
`pymat/vis/__init__.py` imported `mat_vis_client` unguarded. So importing pymat AT
ALL required a visualisation client, even to reach `pmma` / `water()`.

That makes pymat unusable in every headless context: CI, geometry export, and any
consumer that only wants material data. It surfaced downstream in strata, where the
NEMA phantom fixtures could not be regenerated because nema_wagi.py does
`from pymat.factories import air, water` and mat_vis_client is not installed there
(and is not on PyPI) — see gerchowl/strata#1047.

Guarded the import the same way PIL is guarded in the test suite (#242). The names
stay bound, so module-level references still resolve; calling one without the
package raises ImportError naming the missing extra rather than failing at import.

Nothing about material data depends on the client: factories.py imports only
`.core` and `.properties`, and its `vis={...}` entries are literal appearance dicts
(base_color/roughness/ior). The domain types FinishEntry/Vis/VisDeltas come from
`pymat.vis._model`, not from the client, so they are unaffected.

Verified: `import pymat; water().formula` -> H2O and `pmma` -> "PMMA (Acrylic)" with
mat_vis_client absent, and the full strata NEMA fixture regeneration now runs with
no stub on PYTHONPATH.

Committed locally, not pushed — review before publishing.

* feat(optical): wavelength curves, surface-finish catalogue, declared absences

Grows the optical schema along the wavelength axis so a downstream Monte
Carlo transport engine can stop sampling monochromatically at the emission
peak, and adds a catalogue of measured optical interfaces.

WavelengthCurve mirrors TempCurve — piecewise linear, clamps rather than
extrapolates, validated at construction and therefore at load. TempCurve's
public API is unchanged; both now share the validation and interpolation
helpers. Accessors n_at(L), absorption_length_at(L), emission_at(L) and the
self-absorption pair are deliberately named apart from refractive_index_at(T),
which has meant temperature since #148; overloading on argument type would
have been a silent behaviour change for every existing caller. emission_at
has no scalar fallback — emission_peak is one point on a band, not its shape.

Adds the self-absorption split (absorption_length_matrix / _reabs / reemit_qe).
A photon absorbed in a doped scintillator has two physically distinct fates and
one lumped attenuation length cannot express the difference.

Adds Material._absent — a sidecar, keyed by dotted property path with a closed
reason vocabulary, that distinguishes "nobody looked" from "we looked and the
number does not exist". LYSO needs it immediately: its emission spectrum exists
only in paywalled figures, and its self-absorption channel is measured while its
matrix-loss channel is not.

Adds pymat.surfaces — 30 measured interfaces (21 LBNL + 9 DAVIS) from the
Geant4 RealSurface 2.2 data set, each carrying its exact G4OpticalSurfaceFinish
spelling and a DOI. Surface is its own type, not a Material: it has no density,
formula or mass. Air-gap and optical-contact coupling are a validated closed
vocabulary, so the two are finally distinguishable. Geant4's six analytic
UNIFIED finishes are excluded — they are model selections, not measurements.

Recovers five fields that were on disk with no dataclass slot to land in, so
the loader's hasattr guard dropped them on every load since #147: optical
reflectivity (ESR's 98.5%), dopant, dopant_pct, compliance flammable and toxic.
Moves ferrite's permeability from electrical to magnetic. A regression test now
fails if any TOML key anywhere lacks a field to receive it.

Fixes a stale short alias: sources.py mapped radiation_length to optical.* after
#157 moved the property to nuclear, so mat.cite("radiation_length") silently
resolved to a path no TOML writes.

Populates LYSO optical data with provenance on every value, and declares five
gaps explicitly rather than inventing numbers. Corrects PreLude 420 light yield
from an uncited 34000 to the datasheet's 33200. Adds CC-BY-3.0 to the license
allow-list for JINST.

Refs: #243

* feat(rust): rs-materials 0.3.0 — structured fields, curves, uncertainty, provenance

The crate exposed four Option<f64> optical scalars and silently dropped every
structured field, every curve, all uncertainty and all provenance. It now types
the physics domains in full fidelity — optical, nuclear, mechanical, thermal —
including wavelength spectra, temperature curves, decay components, the
self-absorption split, per-field stddev, _sources and _absent.

manufacturing, compliance, sourcing and vis are deliberately not mirrored.
Material::raw() returns the merged TOML table with parent overlay applied, so
nothing in the database is ever unreachable from Rust. That is a guarantee; a
promise to maintain full parity would decay the first time the two sides were
edited a week apart, which is how schema drift arises in the first place.

Adds SurfaceDb over the new surfaces.toml, with by_lut_surface() as the reverse
index a consumer holding a Geant4 finish name needs, plus family() and
with_coupling(). Coupling is an enum, so air-gap and optical-contact cannot be
confused.

Two parser bugs fixed while in there:

- Inheritance only ever consulted one level of parent, so a grandchild such as
  lyso.Ce.saint_gobain.prelude420 silently lost everything its grandparent
  declared.
- Property groups were replaced wholesale rather than merged key by key, so a
  child overriding light_yield dropped its parent's refractive_index.

PROPERTY_GROUPS was also missing magnetic, vacuum, nuclear, vis and custom;
those avoided being mis-parsed as child materials only by accident.

Behaviour change: formula no longer inherits from the parent node. py-mat's
loader does not inherit it either, so the previous one-level lookup was itself
a silent divergence from the source of truth.

The radiation_length/interaction_length move to NuclearProperties (#157) was
already correct here as of 0.2.0 and is now covered by a test.

Refs: #243

* docs(adr): ADR-0004 — optical transport, surface finishes, the substance/assembly line

Records the separation-of-concerns decision negotiated with strata, including
where py-mat disagreed, and commits the incoming brief so the decision trail is
durable rather than living in one agent's context.

Accepted: py-mat owns substance physics and measured interfaces; the consuming
engine owns what was built and how it is simulated. The operative test is not
"is it about light?" but "did someone measure it, and does the number survive
moving the part?"

Narrowed in two places:

- The catalogue holds measured interfaces only. Geant4's six analytic UNIFIED
  finishes carry no data file and no citation; they are model selections
  parameterised by a fitted sigma_alpha, so they are run-time policy. The three
  bare-air LBNL enum members ship no .dat either and are named in a comment
  rather than given entries.
- default_surface on a material is rejected. That a crystal is polished is a
  fact about the crystal; that a polished crystal is then wrapped in ESR with
  grease rather than Teflon in an air gap is a fact about a detector somebody
  built — the brief's own reasoning. It would also inherit down to every vendor
  variant as a wrapping choice nobody made. The variant itself is accepted and
  tested.

Answers the brief's four open questions with evidence, and corrects three of its
claims about py-mat: rs-materials was at 0.2.0 with the #157 migration already
applied (strata is pinned to a stale release), the optical scalar count was four
not six, and the live drift was on the Python side, in a short alias the brief
did not look at.

strata-optical-response.md is written for the strata agent to read directly from
the filesystem, including the LYSO emission-spectrum finding: no redistributable
tabulated source exists, so the band must be synthesised downstream from cited
parameters and labelled as synthesised.

Refs: #243

* docs(data): state the dispersion-ownership rule once, at the top of scintillators.toml

The refractiveindex.info enricher (#164) owns `refractive_index_dispersion` for
the six materials in its scope, and hand-authored data there would mask the
automated pull. That rule was previously a comment on one ceramic and one
scintillator; it now lives once in the file header with the full in-scope and
out-of-scope lists, and the per-material comment defers to it.

Also records in the strata response which P1 materials were deliberately left
alone (gagg, nai_tl, csi_tl, plastics) and why: the literature sweep behind this
work covered LYSO and BGO only, and populating the rest would mean asserting
sources nobody checked.

Refs: #243

* fix(loader): root materials lost grade/temper/treatment/vendor; gate loader parity

Two bugs, both found by cross-checking the Python and Rust loaders against each
other rather than by reading either one.

`loader.py` built every material with

    grade=grade or parent_material.grade if parent_material else None

which Python parses as `(grade or parent.grade) if parent else None`. For a ROOT
material `parent_material` is None, so the whole expression collapsed to None and
the node's own value was discarded. `pymat.beryllium.grade` was None despite the
TOML declaring "S-200F"; `pymat.esr.vendor` was None despite "3M". Eleven grades
and six vendors were affected across the corpus, plus temper and treatment.
Children were unaffected, which is why it survived this long.

The Rust side merged a child's `tags` over its parent's instead of unioning them,
so `raw()` reported four tags for stainless.s316L where py-mat reports seven —
a divergence from the #132 inherit-and-extend rule. `tags` is now typed on the
Rust `Material` too, so consumers do not have to reach through `raw()` and hit
the trap.

Adds tests/test_rs_python_parity.py, which drives `cargo run --example
dump_parity` and diffs 26 fields across every material against the Python
loader — 144 materials, ~3700 value pairs. It skips when cargo is absent and
runs in the `rust` CI job, the only one with both toolchains.

This is the gate the schema has been missing. The two loaders parse the same
files independently and have drifted before: #157 moved radiation_length to
nuclear and the Rust side lagged, and the strata brief was filed largely because
the crate had fallen behind. Conventions did not catch that; a diff does. It was
mutation-tested by reintroducing the precedence bug above and confirming the
gate fails with a readable diff.

Refs: #243

* docs(briefs): record the loader-parity gate and the two bugs it surfaced

The drift complaint behind the strata brief deserved a concrete answer, not just
a schema that happens to be in sync today. Documents the parity gate, the
root-material identity bug and the Rust tag-merge divergence, and why neither
was findable by reading either loader on its own.

Refs: #243

* fix(schema): close three loader asymmetries found in review

None are blockers; all three are the same shape — a code path that fails one
way in one branch and another way in the other, which is how a bug hides.

1. `_validate_wavelength_slot` was guarded on `isinstance(value, dict)`, so a
   scalar written where a spectrum belongs skipped validation entirely, landed
   in a dict-typed field, and only failed at the first `_at(lambda)` call — far
   from the file that caused it. It now raises at load with the expected shape
   named.

2. `emission_at` validated its wavelength argument only after the
   no-spectrum early return, so `emission_at(300 * ureg.kelvin)` returned None
   on a material without a spectrum and raised on the next material along.
   Validated before the return, matching what the other accessors already did.

3. Rust `Curve::from_wavelength_toml` picked the first of `values` / `n` /
   `intensities` present, where Python raises on ambiguity. A file naming two
   would have been interpolated against whichever column the function happened
   to check first. It now refuses, and the loader uses a new keyed variant that
   names each slot's column explicitly — so writing the wrong column yields no
   curve rather than a curve built from the wrong data.

Also types `temper` on the Rust `Material` and extends the parity gate to 28
fields, now including `temper`, `tags`, and the `_sources`/`_absent` KEY SETS.
The Rust provenance parsers use `filter_map` and would drop a malformed row
silently where Python raises; comparing key sets turns that silence into a
diff. Mutation-tested by simulating a dropped row and confirming the gate
names the missing path.

Refs: #243

* feat(data): run the refractiveindex.info enricher; derive reflectance from n,k

Runs `scripts/enrich_from_refractiveindex.py --write` for all nine in-scope
materials, which had been sitting empty since #164 added the tooling. This is
the action ADR-0004 §10 called for rather than hand-authoring the tables.

Scintillators now carry CC0 dispersion: bgo (Williams 1996, 305-1000 nm), nai,
nai.Tl, csi, csi.Tl, csi.Na (Li). The BGO case is the one that mattered — the
single scalar 2.15 is fitted near the 480 nm emission peak, so n at the blue
edge of the band was ~2% low and every critical angle computed there was wrong
in the same direction.

Metals now carry n AND k: aluminum (Rakic Lorentz-Drude), copper and gold
(Johnson & Christy). With k on disk, reflectance stops being a number someone
types and becomes one the database derives:

    OpticalProperties.k_at(lambda)
    OpticalProperties.normal_reflectance_at(lambda)   # percent

The second is the Fresnel result for a semi-infinite medium against vacuum.
Aluminium comes out at 92.5% at 420 nm, 92.3% mean across 400-500 nm, with the
characteristic interband dip at 800 nm — which is now a test, because if either
the CC0 pull or the derivation breaks, that shape is what stops looking right.
Deriving beats storing here: a hand-entered reflectivity scalar can drift away
from the n,k it is supposed to be consistent with, and nothing would notice.

The enricher appends new tables at the end of a material's span, which lands
them below the following section banner — `[aluminum.optical]` under
"COPPER & BRASS", `[copper.optical]` under "TUNGSTEN". Harmless to the parser,
confusing to the next human, so the three blocks are relocated into their own
sections. The enricher's placement is worth fixing separately.

Refs: #243

* docs(adr): sharpen ADR-0004 §3 — the test is measurement, not LUT-backing

The original wording said "measured interfaces only" and then illustrated it
entirely with LUT entries, which reads as "LUT entries only". That would exclude
a pressed-BaSO4 reflector or an aluminium wrap, which are exactly as measured as
a Janecek goniometer sweep — they simply produce R(lambda) rather than an
angular table, and the Surface schema has carried `reflectivity` and
`reflectivity_spectrum` fields from the start.

Restates the line where it actually falls: an entry qualifies when the optical
numbers it carries are measured and citable. What stays out is an entry whose
only content is a fitted model parameter with no measurement behind it — a
`sigma_alpha` tuned until the simulation matched is a knob, not a fact.

Also records the consumer-side consequence, which is the reason these entries
are named for the reflector and never for the crystal: a `lut` entry hands over
an angular distribution directly, while a `diffuse`/`specular` entry hands over
R(lambda) and the consumer composes it with the crystal's n(lambda). That
composition depends on both materials, so it belongs on the side that knows
which two are being joined.

Refs: #243

* feat(data): BaSO4 reflectance, SiPM windows, second couplant, non-LUT surfaces

Everything the concrete 8x8 LYSO / BaSO4-septum / Al-wrap / grease-SiPM module
needs, driven by a downstream sensitivity result showing reflector loss (25% at
the readout end to 63% at the far end) dominates bulk loss (13-29%). That makes
reflector reflectivity the highest-value number in the whole model, ahead of
more scintillator bulk data.

BaSO4 as a diffuse reflector. Grum & Luckey 1968 (doi:10.1364/AO.7.002289),
the primary reference for pressed BaSO4 as a reflectance standard: 0.999 at
420-470 nm, falling to 0.985 at 350 nm. The consumer's working estimate was
0.97, and over ~40 bounces that is 0.296 against 0.961 — the difference is not
a refinement, it is a different model. Recorded with the caveat that matters:
these are pressed powder at high packing density in an integrating sphere, and
the paper's own BaSO4/PVA paint measures 0.992, so a real septum sits below.

Also carries Kubelka-Munk coefficients (Patterson 1977,
doi:10.1364/AO.16.000729), which answer a question that decides model
structure rather than just a number: s = 572 /cm at 500 nm gives a ~17 um
scattering mean free path, so a 0.2 mm septum is ~12 scattering lengths and is
effectively optically thick — no need to transport into it. But only just, and
looser-packed coatings at that thickness may transmit, which would appear as
inter-crystal crosstalk a surface-only model has no channel for.

SiPM entrance windows, both variants, because the datasheet distinguishes them
and the field does not: S13360 CS packages use SILICONE at n = 1.41, PE
packages EPOXY at n = 1.55. The widely quoted "Hamamatsu window is 1.55" is the
PE part. From BC-630 grease at 1.465 the two are qualitatively different — CS
steps the index DOWN and puts a TIR cone at the readout face, PE steps it up
and does not. Also adds DOWSIL Q2-3067 as the named alternative couplant; its
70% transmission at 400 nm against BC-630's flat ~95% is a real difference for
a 420 nm emitter.

New schema: `reflectivity_spectrum` and `transparency_spectrum`, with
`reflectivity_at` / `transparency_at`. The first was already registered in the
loader's validation table with no dataclass field behind it — validated, then
silently dropped. That is the same bug class this branch audited the corpus
for, reintroduced by me, in the one direction the corpus scan cannot see (no
file used the slot yet). There is now a structural test that every validated
slot has a field.

Surface catalogue grows two non-LUT entries, diffuse.baso4_air and
specular.aluminium_air, per the sharpened ADR-0004 §3. They reference their
reflector's material rather than copying R(lambda), so there is one copy to
maintain and nothing to drift.

`contact.grease_sipm` was requested and is refused: it would carry no measured
number of its own, only a pairing of two materials that each already carry
theirs, and pairing is assembly. A test pins its absence so the reasoning is
not quietly reversed.

Refs: #243

* docs(adr): ADR-0004 §11 — a photodetector is not a material

Answers the question strata asked twice and explicitly asked us to decide
rather than assume. A SiPM does not go in py-mat: PDE is a device's response at
an operating point, not a property of a substance, and it does not survive
being re-biased even though it survives being moved. DCR, crosstalk and
afterpulse are worse on the same axis — dark count rate varies 2-3x unit to
unit inside a single part number.

Records where it does go instead, with a real revisit trigger rather than a
brush-off, and notes the supporting fact that settles it independently: no
redistributable tabulated PDE(lambda) exists for the S13360-3050CS at all. The
datasheet has a figure, not a table, and no CC-BY paper measures that exact
part — so putting it here would mean shipping a digitised proprietary figure,
which the provenance policy already forbids.

The two window materials are the part that IS ours, and they are now present.

Refs: #243

* docs(briefs): record round 2 — the concrete detector and what re-ordered it

The consumer came back with a sensitivity result rather than a request, and it
changed the work order: reflector loss dominates bulk loss by roughly 2x with
depth, so reflector reflectance provenance outranked more scintillator data.
Worth recording as a pattern — stating sensitivity before requesting data is
what made the prioritisation correct rather than lucky.

Records the BaSO4 result (0.999 against a working estimate of 0.97, which over
40 bounces is 0.96 against 0.30), the optical-thickness finding that decided
model structure rather than a value, the derived-not-stored aluminium
reflectance, the n=1.41 vs 1.55 window correction, and the two refusals.

Also records a bug I introduced and then caught: `reflectivity_spectrum` was
validated by the loader with no field behind it. That is the same silent-drop
class this branch audited for, in the one direction a corpus scan cannot see —
an invariant has to be checked against the schema, not against the data that
happens to exist today.

Refs: #243

* docs: retract a superseded claim; record that sensitivity is not a property of one parameter

The round-2 sensitivity result that re-ordered this branch's priorities has been
retracted downstream. It was computed at an ASSUMED reflector reflectance of
0.97; at the cited value the model inverts — far-end wrap loss falls 64% -> 5%
while bulk loss rises 29% -> 69%. The reflector does not dominate. The
re-prioritisation was still correct, but for the opposite reason to the one
given: reflectance provenance mattered because the assumed value was wrong, not
because reflector loss is the dominant channel.

The claim is struck through in strata-optical-response.md rather than deleted.
A durable record that quietly edits away a retracted conclusion is worse than
one that never made it — and this is a case of right-answer-from-wrong-reasoning,
which is the hardest kind to notice later.

Also records the finding underneath it, as ADR-0004 §12: the two parameters
interact, so no standalone sensitivity is meaningful. Bulk absorption moves
collection efficiency +17% at R=0.97 and +53% at R=0.999. "X is second-order" is
never a property of X — it is a property of X at whatever value of Y was
assumed, and when Y is the parameter without provenance, the conclusion inherits
that gap silently.

Two consequences for what this repo ships, both already practised on this
branch and now written down: a bracket beats a point estimate when the quantity
is consumed non-linearly (BaSO4 enters as R^40, so 0.97 vs 0.999 is 0.30 vs
0.96), and the uncertainty can be load-bearing in both directions at once — the
same sweep shows a better reflector destroys DOI resolution, collapsing the
depth gradient 8.4:1 -> 1.6:1.

Strengthens the `[lyso.optical] absorption_length` source note with the measured
interaction, since the 200 mm Monte-Carlo convention is more dangerous than the
standalone figure suggested, and raises the recorded priority of the absent
matrix-loss channel. Both caveats are now pinned by tests, because the caveat is
the part a later editor tidies away and it is the part that was load-bearing.

Refs: #243

* feat(optical): Kubelka-Munk coefficients — the crosstalk channel, and a correction

A downstream crosstalk measurement (~15% light share into direct neighbours,
against a model producing 0% by construction) refuted an argument I made two
rounds ago, so this both corrects it and ships what closes it.

WHAT I GOT WRONG. I said a 0.2 mm BaSO4 septum is ~12 scattering lengths and
therefore "effectively optically thick — you do not need to transport into it".
That conflated two different questions. Reflectance converges to its thick-layer
limit quickly; transmittance does not. The K-M finite-layer solution at 420 nm
gives T = 7.6% through 0.2 mm at pressed-pellet density, and still ~2% at the
0.5-0.6 mm vendors recommend. In a segmented detector that transmission IS the
inter-crystal crosstalk channel, so the conclusion was wrong in exactly the way
that mattered.

Adds `optical.kubelka_munk = {wavelengths_nm, k, s}` as a bundled pair rather
than splitting k and s across `absorption_coefficient` and `scattering_length`.
They are one model's parameters, only their ratio is meaningful for the
thick-layer limit, and `s` is not a transport mean free path — keeping them
together under the model's name stops them being read as general optical
constants. It is the first multi-column structured slot, so the loader now
validates every column, not just the first.

Accessors: km_k_at, km_s_at, km_reflectance_infinite_at, km_transmittance_at,
mirrored on the Rust side. Verified the hard way — the thick-layer relation
reproduces Patterson's own published R_inf (0.9624 / 0.9815 / 0.9846) to five
decimal places from the stored coefficients, at all three wavelengths, on both
language sides.

AND A DEFECT THIS SURFACED IN DATA I ALREADY SHIPPED. baso4 now carries two
cited primaries that disagree: Grum & Luckey give 99.90% at 420 nm, Patterson's
coefficients imply 97.18% — a 2.7 point gap, 1.3-2.3 points across 300-700 nm.
Both are real measurements of pressed BaSO4 at different packing densities.
Neither is wrong, and I had shipped both without noticing they were
inconsistent. The gap is not noise, it IS the packing-density sensitivity of
this material, and it is the physical justification for shipping a bracket
rather than a point estimate. Documented on the material and pinned by tests in
both languages, because carrying two inconsistent numbers is defensible only if
the material says so.

Refs: #243

* feat(optical): finite-layer K-M split — R(d), T(d), A(d), and reframe BaSO4 around it

The consumer closed the contradiction and it was not the one either of us
thought: a high semi-infinite reflectance and a septum that leaks 15% are the
SAME finite-thickness solution, not two competing facts. R_inf is what a thick
pile returns; a 0.2 mm layer returns ~92% because the balance goes straight
through, and that transmission IS the crosstalk channel.

The gap that exposed: this repo shipped T(d) but not R(d). The accessor a
consumer actually needs was missing, and the one they had — the semi-infinite
reflectance — is the one they should not use for a real layer.

Adds `km_split_at(lambda, thickness_cm, backing)` returning (R, T, A) in
percent, summing to 100 by construction, plus `km_reflectance_at` for the
common case. Both mirrored in Rust. Photons are conserved by the return type
rather than by convention, which is why the split is one call and not three.

Reframes the baso4 header on the consumer's own suggestion, and it is a better
shape than what was there: a bracket answers "what is this material's
reflectance" when the question a detector builder actually has is "what will MY
layer reflect". So the header now ships the formula and a worked R/T/A table by
thickness, and says plainly DO NOT adopt reflectivity_at() unless your layer is
optically thick. A formula does not need a bracket.

It also explains the 0.5-0.6 mm vendor recommendation without hand-waving:
that is where transmission falls under ~3% and a coating starts behaving like
the reflectance standard it is made of. The earlier packing-quality explanation
was pointing at the right region for an adjacent reason; the thickness term
does most of the work.

One limit pinned because it is easy to get backwards: with k = 0 the thick-layer
reflectance is EXACTLY 1, not 0.999. Absorption is not a small correction to a
non-absorbing model — it is the entire reason R_inf sits below unity. Finite
thickness drives R below R_inf; k sets R_inf itself. The two effects are
separable and the tests now say so.

The header's worked table is itself pinned by a test, so prose and behaviour
cannot drift apart.

Refs: #243

* feat(optical): oblique incidence on the K-M accessors; resolve a struck claim to a condition

The consumer retracted the packing fit built on my numbers, and the retraction
lands on something I had already struck. Both corrections are here.

WHAT THEY FOUND. Their 47%-of-pellet packing fit came from identifying
per-encounter transmittance with observed light share — different quantities,
since a photon meets a septum ~40 times in a 3x3x25 crystal. The real mechanism
is angular: TIR-trapped light meets the side walls at ~77 degrees, so the path
through the septum is d/cos(theta) ~ 4.3d. At that path the same Patterson
pellet coefficients give R = 96.9 / T = 1.4 / A = 1.8, with NO packing
correction anywhere. The fitted quantity is geometric and belongs to their
crystal, not to this material.

That fit was offered here as a property of BaSO4 and was declined on the
grounds that a fit against one module measures that module. Worth recording
that the instinct paid: accepting it would have entered an arithmetic error
into this corpus as a cited material constant.

WHAT IT DOES TO MY OWN RETRACTION. I struck "0.2 mm is optically thick" when
the finite-layer solution gave T = 7.6%. That figure is normal incidence. At
77 degrees T = 1.35% and R reaches 96.9% against a 97.18% semi-infinite limit —
so the original claim holds in the regime that actually applies.

It is not reinstated, because an unqualified claim that happens to hold in the
applicable regime is still unqualified, and the refutation was missing the same
term the claim was. It is recorded at THREE states — asserted, retracted,
resolved-to-a-condition — because "struck, then reinstated with a condition" is
a shape a two-state record cannot represent, and it is what occurred. The
honest form is neither: a 0.2 mm septum is optically thick for grazing light
and is not for near-normal light, and which applies is the consumer's fact.

THE FIX THEY ASKED FOR. `incidence_deg` on km_split_at / km_reflectance_at /
km_transmittance_at, plus a public `obliquity_factor`, mirrored in Rust. Capped
at 40 near grazing, where a plane-parallel slab model has stopped describing
anything. Verified two ways: it reproduces their 76.7-degree numbers exactly,
and doubling the path by angle equals doubling it by thickness.

This is the failure mode named last round, arriving from the other side.
km_transmittance_at was correct, cited, tested and complete for the question it
answered — normal incidence — and it steered a consumer wrong because their
question was 77 degrees. Not a wrong value and not a missing one: a right one
answering an adjacent question. Defaults are an answer, and this one was
answering for a geometry the caller did not have.

Refs: #243

* fix(optical): retract the grazing-incidence narrative — a diffuse reflector erases the angle

Second retraction on the same number, and this one lands on text I committed
last round. The consumer instrumented their proposed mechanism's own prediction
rather than the value it was fitted to, and it died: measured mean side-wall
incidence is 47.8 degrees, not the claimed 76.7 — a factor 2.9 in path length.

The reason was in front of both of us. A BaSO4 septum is LAMBERTIAN, and a
diffuse reflector randomises direction on first contact: mean |cos| = 2/3
exactly, i.e. 48.2 degrees, INDEPENDENT of aspect ratio. The grazing-incidence
story reasoned carefully about the angular distribution of TIR-trapped light
while forgetting that the wall it was reasoning about destroys that
distribution. It is only valid for a specular wall.

What that retracts here, all of which I had written down:

- ceramics.toml told readers to pass incidence_deg=76.7 for a wrapped crystal
  and asserted a 0.2 mm septum "effectively IS" optically thick at that angle.
  Both gone. At the Lambertian mean it transmits ~5%, at normal ~7.6%, and it
  is not optically thick in either case.
- The response doc recorded the claim at three states, ending in
  "resolved to a condition". It now has four, ending in "falsified", and the
  round-3 retraction stands as originally written.
- Tests in both languages asserted 76.7 degrees as the physical angle in a
  wrapped crystal. The MATHEMATICS was and is correct, so the assertions
  survive as accessor tests with 76.7 relabelled an arbitrary long-path
  example; the physical claims around them are replaced.

And a trap I built while fixing the last one. Offering `incidence_deg` invites
exactly the error that followed, because Kubelka-Munk k and s are ALREADY
defined for diffuse flux — the obliquity is averaged into them, which is where
the factor 2 in the usual K = 2k convention comes from. For a diffusely-lit
layer, plain d is correct and multiplying by 1/cos double-counts. A knob with
no statement of when to leave it alone is another way of answering an adjacent
question. Docstrings in both languages now lead with when to pass 0.

New executable physics, because two plausible mechanisms died the same way:
Lambertian mean cosine is 2/3; <1/cos> = 2 while 1/<cos> = 1.5 (Jensen, and the
natural mistake is to reach for the second); and evaluating transmittance at a
mean angle is not averaging it over the distribution, though here the two agree
within ~0.1 points.

The lesson, in the consumer's sharper phrasing: the second constraint has to be
a prediction of the MECHANISM, not another property of the outcome. Their
crosstalk shape was a real second constraint and killed the packing fit, but it
said nothing about angle, so it could not distinguish grazing incidence from
anything else giving the same transmittance. Only instrumenting the angle could.

Refs: #243

* fix(optical): guard the thick-layer overflow; record the degeneracy that hid a wrong mechanism

Found while checking a correction from the consumer, which was itself correct:
the falsification test I proposed last round CANNOT FAIL. R_inf depends only on
k/s, so thickness cancels and a thick-layer check against Patterson's published
value passes identically for a multiplier of 1, 4.34 or 10. They ran it instead
of accepting it.

That is the third instance in this exchange of the same shape: a fit constraint
that could not touch the mechanism, a mechanism that could not be separated from
its rival by any output, and now a falsification test blind to the error it was
proposed for. All three were real checks; none could fail in the relevant way. A
test the wrong model passes is not a weak test, it is a non-test, and the way to
tell is to ask what would have come back had the thing been wrong.

Running that check at a 50 cm layer surfaced a genuine bug: km_split_at raised
OverflowError, because cosh overflows an f64 above ~710. Now branched at
bsd > 20, where coth is already 1.0 to machine precision. The branch is EXACT
rather than asymptotic — coth -> 1 gives R = 1/(a+b), and since
(1+x+sqrt(x^2+2x))(1+x-sqrt(x^2+2x)) = 1 identically, that IS R_inf. Fixed in
both languages, with continuity across the cutover pinned.

Also documents the degeneracy underneath the whole episode: thickness_cm and
incidence_deg enter only through their product, so (0.02 cm, 76.7 deg) and
(0.087 cm, 0 deg) are byte-identical. That is why a falsified mechanism kept
producing correct numbers — "the light arrives at 77 degrees" and "the layer is
4.3x thicker than nominal" are the same claim in different clothes, and no
check on the output can separate them. A consumer fitting against R or T is
fitting the product and should say so.

Refs: #243

* test: mutation-audit the optical gates; close the one non-test it found

The consumer applied the "what does this return under the failure you are
hunting" diagnostic to their own suite and found a non-test on the first pass.
Accepting that lesson without running it here would have been the inflation we
had just agreed to stop, so: ten mutations against the load-bearing optical
paths, each executed rather than reasoned about.

Nine detected. One did not:

  _absent stops inheriting  ->  all 1217 tests stayed green

A child declaring its own absence would silently drop every absence it
inherited. The existing test was blind for a specific reason worth recording:
it used the SAME key on parent and child, where `{**parent, **child}` and
`dict(child)` are indistinguishable. The distinguishing case is DISJOINT keys,
and nothing exercised it.

Three further findings from checking rather than assuming:

- `_sources` was NOT affected — the pre-existing suite catches the same
  mutation there, because shipped materials actually depend on that merge.
  The gap was specific to the newer mechanism, which has no corpus behind it
  yet. New mechanisms are exactly where this audit pays.
- The Rust side had the same hole, AND the cross-language parity gate — built
  in this branch specifically to catch drift — did not see it either. It
  compares what the CORPUS exercises, and no shipped material declares its own
  `_absent` under a parent that also has one, so the branch is dead data-side.
  A gate can only be as good as the data it runs over. Closed with a synthetic
  Rust fixture rather than by inventing corpus data to exercise a branch.
- The overflow bug fixed last commit was the same shape: an absent assertion in
  a region nobody was looking at, because the only tested layer was the thin
  one the author cared about.

Adds scripts/mutation_audit.py so this is repeatable rather than a one-off, and
verifies the new gates catch the mutations they were written for — auditing the
auditor, which is the consumer's phrase and the obviously correct next step.

Refs: #243

* docs(briefs): round 5 — the discrepancy did not exist, and the data never moved

The consumer's fourth retraction resolves the thread: the measured ~15%
crosstalk was per direct neighbour normalised to the central crystal, while the
simulation reported direct-neighbour light as a fraction of total collected — a
factor of ~3.4. With the target corrected, Patterson's coefficients at nominal
0.2 mm reproduce both crosstalk (15.95% vs ~15%) and energy resolution, with
ZERO fitted parameters.

Records what this repository got wrong. Three mechanisms were invented to
explain a discrepancy that never existed, and this repository ENDORSED the
third — "the first version that predicts something outside the model it was
fitted to, refutable by a microscope". That endorsement was correct on its
merits and wrong in outcome: the microscope would have returned 0.2 mm, and the
consumer would have concluded Patterson's coefficients fail for their geometry.
A false refutation of correct data, reached through a sound falsification test,
one measurement away.

The lesson supersedes the earlier four in scope: a wrong comparison manufactures
physics to explain itself, and every falsification downstream inherits the
error. Rule 4 was "a test the wrong model passes is a non-test"; this is worse —
a test the RIGHT model fails, because the target is wrong. Every method in this
document was applied correctly and none could see it, because all of them were
downstream of the comparison.

Also records the tell, which was present twice and read as physics both times:
the "wrong tail shape" that killed the first mechanism was the same
normalisation error, comparing totals across 4 direct crystals against 59
further ones, which inverts the ordering combinatorially.

And the practical form: cheap checks get skipped in proportion to how
uninteresting they are, which is uncorrelated with how often they are the
answer. This one was on the consumer's own candidate list and was passed over
three times.

No data changed. Every value survived four retractions unchanged, and the three
schema refusals held — which is the strongest available argument for ADR-0004
§1's line that a fit against one module measures that module.

Refs: #243

* feat(data): LSO:Ce intrinsic resolution + non-proportionality; declare the 511 keV gap

The consumer's model produces an implied LYSO intrinsic resolution of 6.26% and
was being judged against a 7-9% band they had asserted from memory — the only
uncited quantity in their comparison, in a session where four of their uncited
numbers had already been wrong. They asked whether a cited figure belongs here.
It does: intrinsic resolution is a property of the scintillator, not of the
module, so by ADR-0004 §1 it is ours.

What is citable, now landed on `lso.Ce`:

  intrinsic_resolution_pct_at_662keV = 7.7 +/- 1.0   Chewpraditkul & Moszynski
                                                     2011, doi:10.1016/j.phpro.2011.11.035
  non_proportionality = 43.0                         Khodyuk & Dorenbos 2012,
                                                     doi:10.1109/TNS.2012.2221094

The extraction method is recorded with the value because the value is
inseparable from it. R_int is NOT measured — it is the residual after an
assumed photostatistical term is subtracted in quadrature, so it inherits
whatever the authors assumed for light collection. Chewpraditkul's figure comes
from a 10x10x5 mm crystal on a PMT with N_pe = 6610; reproduced from their
stated inputs as 7.72% against a published 7.7%. A consumer extracting R_int
from a 3x3x25 mm crystal on a SiPM is not producing a comparable quantity.

The +/-1.0 is deliberately NOT the paper's error bar (which is +/-0.3, on the
total, not the residual). It widens for two effects the paper cannot capture:
extraction-assumption sensitivity, and sample-to-sample spread — Khodyuk &
Dorenbos Table I gives published LSO:Ce totals spanning 7.9-11.9%.

What is NOT citable, declared absent on both `lso.Ce` and `lyso`:

  511 keV: no primary source reports an extracted intrinsic resolution at that
  energy for either material. Extraction is conventionally done at 662 keV
  where the subtraction is most stable. R_int is energy-dependent, so the
  662 keV figure is not a substitute, and 511 keV values exist only inside
  paywalled FIGURES. Reading one off would produce a number with the appearance
  of a citation and none of the substance.

  LYSO at 662 keV: exists but paywalled (Wanarak 2012, Sreebunpeng 2019). The
  absence note states explicitly that borrowing the LSO number would bias HIGH,
  since Wanarak measures LYSO total 8.2% against LSO 10.6%.

Also relocates three more enricher-misplaced `refractive_index_dispersion`
lines (bgo, csi.Tl, csi.Na) that were filed visually under the following
section banner. Same defect fixed in metals.toml earlier; found here because
inserting a table adjacent to one of them would have silently captured it.

Refs: #243

* test(integrity): gate the banner-capture defect I fixed twice by hand

The consumer's closing observation is that all three latent defects in this
exchange were POSITIONAL — the enricher appending past a section boundary,
their liveness check dead for models the corpus never used, and my `_absent`
override untested because parent and child shared a key. Each was invisible to
a test of the thing itself and visible only when something adjacent moved. They
noted they had no general answer for making that class routine.

A partial one, for the shape I hit: when the invariant is about STRUCTURE
rather than behaviour, it is often checkable without knowing what anything
means. "Every key is adjacent to its table header" needs no domain knowledge,
so it can be a lint rather than a judgement.

Adds that lint over every shipped TOML. A key separated from its table header
by a SECTION BANNER parses as part of the preceding table but reads as part of
the following section — correct to the machine, misleading to a human, and a
trap for whoever inserts a table beside it next. Which is precisely how it
surfaced: adding an `[lso.Ce._absent]` table adjacent to one would have
captured it.

The distinction that makes the check usable is banner-versus-prose. Most values
in these files carry an explanatory comment and flagging those would make the
check unusable — an unusable check gets deleted rather than obeyed. Only a
box-drawing divider counts, since only that indicates a section boundary.

Verified against the real defect rather than only a synthetic one: replanting
the historical `[aluminum.optical]` misplacement makes it fail, naming the
wrong owner (`[aluminum.a2024.mechanical]`). Also pinned against false
positives on prose comments and on multi-line arrays like `decay_components`.

Worth noting this was hand-fixed twice — metals.toml, then scintillators.toml —
before being written down as an invariant. Fixing an instance twice without
gating it is its own smell, and the second fix should have been the trigger.

Refs: #243

* build(pre-commit): gate the two silent data-shape failures found in #243

Answers "what should be a hook so this does not recur". Two of the session's
defect classes are cheap, structural, and were each invisible for months
because NEITHER PRODUCES AN ERROR:

  - a TOML key with no dataclass field is parsed and then discarded by the
    loader's `hasattr` guard. `[esr.optical] reflectivity = 98.5` was dropped
    on every load from #147 until #243; an audit then found four more.
  - a key appended past a section banner parses correctly but reads as part of
    the next section, so the next insertion beside it captures it. Hand-fixed
    in metals.toml, then again in scintillators.toml, before anyone wrote it
    down as an invariant.

Both belong pre-commit rather than only in CI, because both appear at the
moment an adjacent thing moves — which is the moment of the edit, not minutes
later in a pipeline.

ONE IMPLEMENTATION, NOT TWO. The logic lives in scripts/check_data_shape.py
and the existing tests now call it: the corpus-level test invokes the script
through its real entry point, and the unit-level placement tests import the
same function the hook runs. Adding a hook that duplicates a test would create
exactly the drift these gates exist to prevent, so it does not.

Stdlib-only, matching check_licenses.py: it parses properties.py with `ast`
rather than importing pymat, so pre-commit can run it in a clean isolated
interpreter with nothing installed. Verified on bare python3.

Verified against the real defects, not synthetic ones — replanting the
historical `[aluminum.optical]` misplacement and a bogus optical key both fail
with the offending path named. Also pinned against the two false positives that
would make it noise, and therefore ignored: prose comments before a key, and
multi-line arrays like decay_components.

NOT made hooks, deliberately: the mutation audit (runs the full suite ten
times) and the Rust/Python parity gate (needs cargo) are too slow for the edit
loop and stay in CI and manual use.

Refs: #243

* docs(readme): cover the new public surface — wavelength optics, absences, finishes

The README is generated from tests/test_readme_examples.py, so an addition to
the public API that is not exercised there is invisible to users even though it
ships. This branch added three user-facing capabilities with no README
coverage.

Adds three examples, each of which is a real executable test:

- wavelength-dependent optics, using BGO to show why it matters — the scalar
  refractive index is fitted near the emission peak, so n at the blue end of
  the band is understated, and the clamp-not-extrapolate contract is visible
  through `range_nm`
- declared absences, showing the distinction the mechanism exists for: LYSO's
  emission spectrum is absent-with-a-reason, while a property nobody has
  spoken about stays silent
- the measured surface-finish catalogue, showing the air-gap versus
  optical-contact distinction on the same reflector

27 -> 30 examples; README regenerated and matching.

Refs: #243

* fix(ci): typos allowlist for TiO2 and Hass; correct a test pinning an uncited value

Two CI failures, both real.

TYPOS. The `typos` hook rewrites three things this branch introduced, and all
three rewrites are wrong:

  tio  -> to     `tio` is TiO2, titanium dioxide reflective paint. It appears in
                 the Geant4 RealSurface finish names (polishedtioair,
                 etchedtioair, groundtioair) and in the catalogue keys derived
                 from them. The rewrite would silently rename three measured
                 surfaces — and note it caught the KEYS while leaving the
                 verbatim G4 enum strings alone, so the two would have drifted.
  Hass -> Hash   G. Hass, "Filmed surfaces for reflecting optics", JOSA 45, 945
                 (1955). A surname, cited for evaporated-aluminium reflectance.
  mis  -> miss   "mis-parsed" as a prefix, in a Rust doc comment.

TEST. `test_apply_scintillator_to_shape` pinned prelude420's light yield at
34000. That was the uncited round-up this branch corrected to the Luxium data
sheet's 33200, so the test was asserting the defect. Updated with the reason
inline.

Worth recording WHY that one reached CI rather than being caught locally: it is
gated behind `pytest.importorskip("build123d")`, and build123d pins
`python_version < '3.13'` while this machine is on 3.13. The local suite skips
64 tests where CI skips 54, so ten tests never ran here. I swept the
build123d-gated files for any other assertion touching data this branch
changed, and checked the corpus for tests asserting a root material's grade or
vendor IS None — the case my loader fix inverts. Nothing else is affected.

Refs: #243

* fix(optical): remove backing_reflectance — it returned negative absorption

BLOCKER from pre-merge review, verified and reproduced identically in both
languages:

  k=0.1, s=572 /cm, d=0.2 mm, Rg=0.9  ->  R=95.13  T=7.97  A=-3.09
  k=0.001, s=1 /cm, d=1 cm,   Rg=0.5  ->  R=66.58  T=49.94 A=-16.52

The docstring claimed the tuple sums to 100 "by construction" and that A is
"the only true loss". The sum held — because A was DEFINED as 100-R-T — but the
interpretation did not. With a reflective backing, Kubelka-Munk's R is the
reflectance of the COMPOSITE, layer plus backing, including light that crossed
the layer, bounced, and came back. T remains the layer's own transmittance.
They are not two parts of one photon budget, so the remainder stops meaning
"absorbed" and goes negative. The k=0 branch additionally ignored the parameter
outright, so a non-absorbing layer on a perfect mirror returned sd/(1+sd)
rather than R -> 1.

REMOVED rather than repaired. Getting it right means first deciding what a
three-way split even means when R and T are measured against different
references, and that is a physics question with no consumer waiting on it — the
one downstream user passes the default. Shipping a knob whose documented
meaning I got wrong is worse than not shipping it. It can return when there is
a physical definition and someone who needs it.

At Rg=0 the numbers are unchanged and correct: a transmitted photon is gone,
which is the right model for an inter-crystal septum and every use this branch
has.

Why the suite missed it, which is the part worth keeping: the single test
touching the parameter asserted `bright > black` — an ORDERING property. That
passes throughout the defect. A conservation claim has to be tested as a
conservation claim, and the replacement sweeps 24 wavelength/thickness
combinations asserting A >= 0 and R+T+A == 100. Removal is pinned too, so
re-adding the parameter is a deliberate act rather than a merge artefact.

Also from review:
- Rust `Absent::from_toml` accepted any `reason` string while Python enforces a
  closed vocabulary. Data arriving through `MaterialDb::open` on a
  hand-authored directory never passed the Python gate, so it now validates
  against `ABSENT_REASONS` there too rather than inheriting a weaker guarantee
  from whichever path was taken.
- `obliquity_factor`'s cap at 40 fired silently; it now logs at debug when it
  clamps, so "computed" and "capped" are distinguishable.

Refs: #243

* style(docs): apply pymarkdown fixes — fenced code blocks and list numbering

CI's `pymarkdown` hook auto-fixes; the job fails when it changes a file. Two
things in the strata response doc:

- two indented code blocks converted to fenced (MD046)
- an ordered list renumbered 4/5/6 -> 1/2/3 (MD029), which is also the more
  correct reading: the prose introduces it as "three found while implementing",
  so it is a new list rather than a continuation. Checked that nothing in the
  document cross-references those numbers.

Ran through the same pinned version (0.9.23) rather than hand-patching to match
a diff, and swept every markdown file the hook covers to confirm nothing else
drifts. Verified idempotent.

Note for anyone else on NixOS: pymarkdown's `pyjson5` dependency needs
libstdc++ on the loader path, so it fails to import out of the box.
`LD_LIBRARY_PATH=$(nix eval --raw nixpkgs#stdenv.cc.cc.lib)/lib` makes it run.

Refs: #243
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file python:uv Pull requests that update python:uv code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants