Stability & shelf life, recovered from the regulation rather than asserted

A frozen dessert, a botanical capsule and a research peptide are different products with the same problem: how long is it good for, and how do you know? This is how the Nishi lab-science stack answers that — and what it refuses to answer.

The problem with every shelf-life model

Projecting a shelf life needs an activation energy: the number that says how much faster a product degrades when it gets warmer. Almost every model simply asserts one, and from that moment the expiry date is only as good as a constant somebody typed. We did not want another typed constant.

So the activation energy is recovered, not asserted. ICH Q1A(R2) states that six months at the accelerated condition (40 °C) supports a twenty-four month re-test period at the long-term condition (25 °C). Two conditions the regulation itself calls equivalent uniquely determine the kinetics between them. Working backwards from that single claim:

QuantityRecoveredPublished band (never an input)
Acceleration factor over 15 °C3.9814.000 (ICH’s own 24 mo ÷ 6 mo)round trip
Q10 coefficient2.5122.0 – 3.0inside
Activation energy71 623 J/mol60 000 – 100 000 J/molinside

Both recovered figures land inside independently published bands that were never used in the derivation. That is the check: the regulation’s own arithmetic agrees with the thermal-degradation literature. Had our kinetics been wrong, they would have landed outside.

The correction that matters: this number is lumped

ICH’s two conditions are 25 °C at 60 % RH and 40 °C at 75 % RH. They differ in humidity as well as temperature. Two conditions differing in two variables cannot separate two parameters, so what comes out is an effective activation energy carrying the moisture contribution inside it — not a pure thermal one. Our first cut of this presented it as a clean thermal recovery. It was not.

What survives, and what does not:

Separating the two needs a third condition that moves humidity while holding temperature. The engine reports the lumping as a value in every response and refuses to bless a projection outside the 60–75 % RH band it was recovered along — both as functions rather than as footnotes, so nothing downstream can route around them.

So we built the separation, and measured what the lumping costs

Flagging a limitation is not fixing it. The moisture axis is now modelled explicitly — degradation rate rises with humidity as well as temperature — and given three storage conditions instead of two, the engine solves for both at once.

The check is a round trip. We pick a true activation energy and a true moisture sensitivity, generate three lifetimes from them, then hand the solver only those lifetimes and ask it to recover the two numbers it was never told. It returns the moisture sensitivity exactly and the activation energy to within 0.85 %. A solver that could not really separate the axes would come back with something else.

QuantityLumped (2 conditions)Separated (3 conditions)
Thermal activation energy71 623 J/mol44 421 J/mol
Overstatement by lumping612 permil — the lumped figure is 61 % too high
Moisture share of ICH’s acceleration374 permil — over a third of it was never temperature at all

That is why the correction was worth making. Used off-path, a lumped activation energy overstates the temperature sensitivity by more than half, and a product would be assigned a shelf life on the strength of a number that is measuring two things at once.

Solvable and usable are different questions

Three conditions are necessary but not sufficient: they must also point in genuinely different directions. Our first version of this test assumed that stepping temperature and humidity together in equal increments was degenerate. It is not — because 1/T is nonlinear in T, that design is technically solvable, and the code was right where our expectation was wrong.

But it is barely solvable: its conditioning measures 23 permil against 1000 for a real design. A yes/no answer to “can these conditions separate the axes?” would have returned yes and handed back an activation energy with no warning that tiny errors in the measured lifetimes would swamp it. So the engine reports conditioning as a number, and refuses designs where one axis never moves at all.

This is the same discipline the pasteurisation rung uses, where the z-value is recovered from the two schedules the milk ordinance declares equivalent rather than taken from a table.

The finding: an average temperature is not a safe summary

Cold-chain records are usually reduced to an average. That reduction is not conservative — it is wrong in the dangerous direction, and it is wrong by a lot.

A product cycled between 5 °C and 45 °C has an arithmetic mean temperature of exactly 25 °C. It degrades as though held at 37.25 °C.

Measure of the same historyValue
Arithmetic mean temperature25.00 °C
Mean kinetic temperature (Haynes)37.25 °C
Understatement by the naive mean12.25 °C

Degradation rate is convex in temperature, so time spent hot costs more than time spent cold refunds. Averaging throws that asymmetry away and always errs toward saying the product is fine. Our engine reports both numbers in the same response, so the reassuring one can never travel alone.

The convexity that makes this true is not assumed. It holds only where Ea / (R T) > 2, so the engine measures that ratio — it is 28.9 at 25 °C, comfortably inside the regime. A control case confirms the effect comes from the variation and not from the method: a genuinely flat history returns a gap of exactly zero.

Two bugs the gates caught

An overage that rounded the wrong way

To still meet label claim at expiry you overfill by a computed margin. At 93.9 % retention the exact overage is 64.96 parts per thousand; integer division floored it to 64, landing the product at 999 permil of label on the expiry date — non-compliant by construction. Label claim is an inequality, so this figure must round up. Fixed to a ceiling; the round trip now closes.

Two more defects, briefly

A shared temperature primitive was returning “temperature has no effect” for every input. It formed 1/T in a fixed-point format too coarse to hold it, so 25 °C and 40 °C both truncated to the same integer and their difference was exactly zero — in a file whose own comment claimed it managed precision carefully across that step. It had no test, and despite a header listing four consumers, nothing imported it. A primitive with no test and no callers is not a capability, it is an unverified intention. We found it by cross-checking against a second, independent implementation — and kept both, because merging them would have removed the only thing capable of catching it.

A numeric primitive overstated its own accuracy by a factor of two. Its header claimed an interpolation error under 0.3 %; measured against its own functional equation, the real figure is 0.66 %. That number had been inherited into a food-safety margin. We re-checked the conclusion it supported at the corrected value: unchanged, because 0.7 % is still an order of magnitude inside the margin. The claim was wrong; the safety verdict it supported was not.

We stopped finding these by luck

Both defects above were caught because somebody happened to look. That is not a control. So the pattern behind them — nothing checks this file — became a scanner rather than a lesson: it walks the tree, builds the import graph, and asks of every organ whether any test covers it and whether anything actually calls it.

The ranking is the useful part. An unchecked file with fifty callers is a different problem from an unchecked file with none. The first propagates an error into fifty places where it gets blamed on whichever caller notices; the second is inert until someone wires it up trusting its header. Both are reported, ranked by reach, never as one flat list.

One subtlety decides whether the tool works at all: a test that imports the file it tests counts as a caller. So the moment you write a test for a dead file, a naive “is anything using this?” check calls it used. Test files are therefore excluded from the caller count — without that, the dead primitive above would have hidden itself the instant it was gated.

The scanner's first answer was wrong, and checking it was the point

Its first run reported 1,019 unchecked load-bearing files. Before acting on that we spot-checked the top rows — and found the number inflated: our cryptographic hash and TLS implementations were reported as unchecked when both are thoroughly tested, just under names like <organ>_<aspect>_test that a strict naming rule could not see. Corrected, the count is 963.

Fixing it required care in the other direction too. Matching on prefix alone would have credited one organ's tests to a different organ with a shorter name, marking genuinely unchecked code as covered. That error is the dangerous one for a tool like this, because it produces silence rather than a false alarm. The scanner prints every row as a hypothesis to be verified by reading, not as a defect — which is exactly the discipline that caught its own bad first answer.

Run against this project's own lane it returned one hit, and the truth was more specific than the tool could express: the ingredient catalog was partly covered, by a neighbouring file's tests. But those tests exercised the packaging and cost helpers — while the functions deciding whether a substance may lawfully be sold at all were exercised by nothing. The most consequential logic was the least checked, which is the general shape of this defect: coverage accumulates around whatever a neighbour happened to need. Those functions now have their own tests, including that an uncatalogued substance can never come back lawful.

“Has a test” was still the wrong question

That hit exposed the scanner's own blind spot: a test touching four of an organ's twelve functions counted as coverage. So the next tool measures how much of a file its tests actually reach — which functions are defined, and which are referenced by the tests attributed to it.

Two details decide whether such a number is honest rather than merely plausible. Comments are stripped before matching: files here carry long headers that name the functions they discuss, and matching raw text would mark a function covered because someone wrote prose about it — the worse the coverage, the likelier that prose exists, so the measurement would have been anti-correlated with the truth. And matching is token-exact, because a substring match would credit si_class to a mention of si_class_of.

Its first answer was wrong too, and verifying a row is what showed it. The management API scored 15 permil — almost nothing tested. Reading its test file: it drives the API over HTTP and calls one dispatcher function directly, so everything else is exercised through that dispatcher. Scoring it near zero was a measurement artefact, and it was the loudest row in the report.

So reach is computed transitively: start from the functions the test names, then follow the file's own internal calls to a fixpoint. Across 4,775 measured organs that moved fully-exercised from 2,645 to 4,039 and mean reach from 763 to 960 permil. The same correction, applied to our own lane, surfaced four untested functions in the temperature primitive we had just repaired — including two published rules of thumb built directly on the code that had been returning “temperature has no effect”. Fixing a defect and testing only the defect is how its neighbours stay broken. They now check against their published values: a 5 °C drop lengthens seed longevity 1.82× against a literature rule of “roughly doubles”.

What this still does not measure is whether the assertions are any good. A test that calls every function and asserts nothing scores a perfect 1000. Reach is a floor on quality, not quality — and saying so is the difference between a number and a reassurance.

Then we checked whether we were duplicating our own work

The two scanners above were written days apart and shared nine identical functions between them: the directory walk, file reading, path joining, the file-type predicates, and the rule deciding which test file covers which source file. We wrote both copies, while building tools to find exactly this class of problem. The walk had already been flagged as debt twice and filed rather than fixed — and then the next tool added a fourth copy. Naming a debt is not paying it.

The mechanical helpers were cheap to duplicate. The attribution rule was not, and it is the real reason to share: it decides whether a test named for one aspect of a file counts as covering that file, and getting it wrong is what produced the inflated first headline. With two copies, one can be corrected and the other silently left behind — and the two tools would then disagree about which files are tested while both looked healthy. A rule two tools must agree on belongs in one place, which is a stronger argument than any line count.

Removing the duplication immediately created the exact defect the tools hunt. The new shared library had two consumers and no test of its own. Worse, our own scanner did not report it: with two consumers it scored below the fixed floor given to files nothing calls at all, so it sat beneath five thousand dead scratch files in the ranking. A live component with two consumers matters more than a dead one with none, and the ranking said the opposite. Both are fixed — the library has its own tests, and load-bearing findings are now what a reader sees first.

Proving a refactor is harmless turned out to be harder than expected, and the reason is worth recording: this codebase is edited concurrently, and files appeared mid-run from work that was not ours. A whole-tree before-and-after can therefore never be byte-identical. What we could establish is that every ranked finding is unchanged, and that both walk modes in the new library see precisely the same 6,362 files — which is exactly the drift that two copies invited.

One refactor we chose not to ship

A third tool — the one that builds the ecosystem dependency graph — carried its own copy of the same walk, and adopting the shared one seemed obvious. It appeared to add 29 spurious entries to the graph, so we reverted it.

That first diagnosis was wrong. Re-running showed the tree had simply gained files between the two captures, from other people's work. A sequential before-and-after is not a comparison on a moving tree. Run back to back at the same moment, both walks saw exactly the same 6,368 files.

But the honest comparison still showed a real difference: 3 fewer names and 4 fewer edges out of roughly 7,000 and 14,500 — about 0.04 %, reproducible, and unexplained. So we reverted, for a better reason than the one we started with: that component has no tests of its own and the graph it builds feeds the capability atlas, and an unexplained drift there is worse than a duplicated function. A refactor you cannot explain is not a refactor.

Then we explained it, and the answer was “nothing differs”

Counts can only establish that two things disagree. To learn what disagrees you need the actual sets. So we built a comparison that runs both walks inside a single process, microseconds apart, refuses to report at all unless both saw the same number of files, and then diffs the interned names rather than counting them.

The symmetric difference is empty. Identical files, identical names, identical edges. The two walks were always the same walk — and all three apparent deltas were the tree changing underneath us, even between process launches seconds apart.

On a concurrently-edited codebase, the only sound before-and-after is inside one process. Two separate runs diff the clock as much as the code. That comparison is now a permanent test: if anyone changes the shared walk, it fails and names the files that drifted. The duplicate copy is gone, and the walk exists once.

Measuring duplication instead of noticing it

The two scanners were de-duplicated by hand, after the fact, because someone happened to look. The project already had a duplicate-source detector, but it answers a different question — it finds the same file in two source trees, so it structurally cannot see two files in one directory containing the same function written twice. That is the gap that let us duplicate nine functions while holding tools built to find duplication.

One design decision makes it work: names are ignored. Our nine duplicates were called gs_len and gq_len, gs_walk and gq_walk — identical bodies behind different prefixes, which is exactly what copying into a new file produces. A detector keyed on names would have reported a clean tree. So each function is reduced to its body: comments stripped, whitespace collapsed, the signature line dropped, and the remainder hashed.

Across 17,235 files it measured 47,101 function bodies and found 2,541 duplicate groups — 8,800 redundant copies, 3.3 MB written more than once (split below into what is actually debt). Reading the top hit confirmed the premise: a 270-byte integer-printing helper exists in 198 copies, named vn, t_putn, gn, dn, wn and tn in different files. Same body every time, a different name every time.

Its first run reported zero. Zero functions, zero duplicates, verdict green, across seventeen thousand files. The cause was a language detail — there is no break, so the body scan escaped by assigning its own loop variable, destroying the position it had just found. Every body came out empty.

That is the worst failure a detector has, because a tool that reports nothing is indistinguishable from a clean tree. It is the same failure we hit one wave earlier, when the coverage scanner silently ranked a real finding below five thousand dead files. Both are now regression-tested: the duplication detector's test asserts that extracted bodies are non-empty, and that two identical bodies with different names hash the same.

Ranking is by cost rather than count — copies minus one, times body size — because two copies of a sixty-line walk are a different problem from two copies of a one-liner.

That headline was an upper bound, so we split it

Identical bodies are not automatically defects. This project deliberately keeps independent re-derivations of a rule inside its tests, because a test that calls the code under test can only confirm self-consistency — and that choice is what caught the dead primitive described above. Reporting one number for both kinds gives you a figure nobody can act on.

CategoryGroupsMeaning
Test-file only383Deliberate independent re-derivations, kept on purpose
Mixed320Needs a human to judge
Product code only1 838Unambiguous debt — 3 145 copies, 1.5 MB

So the actionable figure is 1.5 MB, not 3.3 — under half what we first reported. The ranking now shows only product-code groups, because those are the ones with no defence.

What surfaced at the top is worth naming. The SHA-512 round-constant table exists in seven copies across the hashing, HMAC and HKDF implementations — called sha512_k in one file and _sha512_k in the others, with byte-identical 3 256-byte bodies. Confirmed by reading, not by trusting the tool. A cryptographic constant table copied seven times is the shape of problem where a correction to one copy silently fails to reach the other six. We have filed it rather than fixed it: that is another team's code, and editing seven cryptographic files on the strength of a scan would be exactly the recklessness this page keeps arguing against.

What is verified, and what is not

Every response this stack emits carries a grounding tier, and the tier is decided per claim rather than per tool. The same service reports VALIDATED for stability physics and ASSERTED for organism thresholds in adjacent calls, so a consumer can never mistake a domain constant for a checked result.

Claim classTierWhy
Peptide mass, molecular formula, isotope envelopeANCHOREDTriangulated against published values
Stability kinetics, pasteurisation zVALIDATEDRecovered from regulation, agrees with literature bands
Freezing curve, potency retentionDERIVEDComputed from validated physics
Costs, integrity scores, regulatory classificationsASSERTEDDomain knowledge, no in-system external check

Honest limits

Evidence

43 gates green across the lane, zero red, 497 assertions — run in full after every change described here, not just on the changed files. The lab-science service rebuilt on the NAS and promoted with its previous binary retained for rollback; fleet health after the deploy was 14 services, none degraded, none down.

Done means a live call returned the right answer, not that a build succeeded. The separation described above was invoked on the deployed service over its agent interface and returned a thermal activation energy of 44 421 J/mol beside the lumped 71 623, with the design conditioning at 1000 permil and the “could ICH’s pair alone have done this?” flag reading false. Every number on this page is reachable that way.

That run is not decoration. It writes knowledge/status/labsci_gate.log, which is the evidence pointer the ecosystem maturity registry reads when it grades this domain. The grade is therefore derived from an execution rather than typed: if a gate goes red, the log says so on the next run and the domain degrades itself. The domain currently grades FUNCTIONAL against a bar of EXCEED, on a single witness, flagged as un-triangulated — because the only witness is our own gate, and pointing a second “independent” pointer at another log we wrote would be theatre. Nothing here has yet been run against a physical instrument, and that is the next rung.