driftlet 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
56
56
  ```
57
57
 
58
58
  Or straight from a CDN in a page, pinned to a version:
59
- `https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js` (and `src/kit.js`, `src/plot.js`).
60
60
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
61
61
  [`llms.txt`](llms.txt).
62
62
 
package/docs/device.md CHANGED
@@ -110,6 +110,11 @@ regions: [
110
110
  reactions) does: its `c0` fixes the amount it conserves. Concentrations are positive ($`\bar\mu`$ is
111
111
  logarithmic in them); to have none of a species in a region, leave it out of that region's
112
112
  material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
113
+ Where two strictly neutral regions meet at a face that holds ions in equilibrium and their
114
+ `c0` leaves it out of equilibrium (a different fixed charge or immobile ion on each side), the
115
+ two nodes at the face start in it, as the face reaches it at $`t = 0^+`$: the ions it passes
116
+ shared between them so their $`\bar\mu`$ is equal across it and each side neutral, each
117
+ one's amount over the two nodes kept, everything else as `c0` has it.
113
118
  An entry can instead be a profile, `{ x: [...], values: [...] }`, against the device's x (m, not
114
119
  the region's own), piecewise linear between its points and constant beyond its ends, so a
115
120
  transient can start from a state it didn't simulate reaching: a packet already injected, a
package/docs/internals.md CHANGED
@@ -83,4 +83,11 @@ The rest of `src/solver.js` is to be split along these lines, each piece with it
83
83
  A review of one module reads its contract, its code and its tests, and nothing else unless the
84
84
  contract points there. Each claim cites `file:line` with the line quoted, so it can be checked
85
85
  mechanically, and a finding comes with the device or script that shows it. A claim about code
86
- not in view is a question, not a finding.
86
+ not in view is a question, not a finding. Two instruments check a change to the core beyond the tests'
87
+ answers: `test/jacobian.test.js` (every row's derivatives against finite differences of its
88
+ residual) and, for a move or refactor meant to change nothing, `tools/fingerprint.js`: a hash of
89
+ the solver's state after every solve in the suite (on a fixed clock, so runs stopped by a time
90
+ budget repeat), recorded before and after and compared. `tools/check-hook.js`, loaded into any run (`node --import
91
+ ./tools/check-hook.js …`: the tests, the law sweep, a fuzzer), checks the laws' cheap invariants
92
+ as the solver goes: no row taken twice, none inactive, no stray ∂/∂V in a law's row, findLaws'
93
+ structure unwritten.
@@ -171,9 +171,47 @@ and in a time step's reading of its current), none of them in the sweep's device
171
171
  `test/laws.test.js`. On the code before those fixes the second family has 14 devices that
172
172
  disagree (a port's voltage at 0 for −1.42 V, θ 0.05 for 0.015, a contact 24 mV off); after, none,
173
173
  26 agree and 34 transients fail (see the roadmap). The first family's laws come out unchanged by
174
- the fixes, device by device, so its results above stand. On 1.2.2 the whole sweep, 870 devices, gives the
175
- same: 605 agree, 117 transients fail, and 13 differ, each a capacitive contact beside a membrane
176
- capacitor where the transient loses K⁺.
174
+ the fixes, device by device, so its results above stand. On 1.2.2 the whole sweep, 870 devices,
175
+ gives the same: 605 agree, 117 transients fail, and 13 differ, each a capacitive contact beside a
176
+ membrane capacitor where the transient loses K⁺. Since a face out of equilibrium in `c0` starts
177
+ in it (1.2.3), 607 agree and 115 fail, the same 13 differing.
178
+
179
+ ## Consistent: the derivatives
180
+
181
+ A Jacobian entry that's wrong still converges to the right answer, its residual being right, so
182
+ no test of answers sees it: it shows only as Newton taking longer, or failing on a harder device.
183
+ `test/jacobian.test.js` compares the whole Newton system, column by column, with central
184
+ differences of its own residual, on ten devices that between them write every law's row (flat
185
+ stretches, affinities, amounts kept in balance rows and in circuit rows with an electrode's or a
186
+ contact's charge, node-by-node laws with a surface, metals' charges), crowded statistics, face
187
+ and port reactions and floating terminals. It checks inside a real `solve()` and a real
188
+ `advance()`, at their second assembly, each in the unknowns Newton solves for there (a time step's
189
+ charge rows included). It came from a cold review of `src/lawRows.js`, which found a node-by-node
190
+ law's row keeping a port reaction's ∂/∂V (an extra Newton iteration, the answer the same); broken
191
+ once each way (that entry back, a storage term 0.1% off in either time-step path), it fails.
192
+
193
+ ## Measured: how much the tests see
194
+
195
+ A mutation run makes one small slip at a time in a module (an operator flipped, a comparison
196
+ loosened, a statement dropped, a 0 for a 1), runs the tests on it, and counts the slips no test
197
+ fails. On 18 Oct 2026, 120 of them, sampled from `src/laws.js` and `src/lawRows.js`'s 1420: 69
198
+ failed a test, and 51 got through. Each of the 51 was then looked at:
199
+ - In `src/laws.js` (29): 20 change no law on any of the law sweep's 735 devices, 2 only a
200
+ workspace's size, and 6 change laws on 12 to 635 of them while every answer stays right (the
201
+ islands', which Newton seldom needs; a λ read only by long steps). One changed the answers: it
202
+ held a capacitive contact's charge in the wrong law, and 85 of the sweep's devices came out
203
+ wrong, with all 470 tests passing. Its device is now in `test/laws.test.js`.
204
+ - In `src/lawRows.js` (22): the Jacobian test had never seen islands' rows, a long step that
205
+ reads a law by its change, or a law with a surface or a capacitive electrode in a time step.
206
+ With them in (a charged pore, three of the sweep's devices), 6 more fail. Of the 16 left, 6
207
+ change nothing (a bound the loop never reaches), 1 only a heuristic's estimate, 2 the pivoting
208
+ of the small dense solve (robustness only), and 7 are narrow paths no device here takes: two
209
+ floating electrodes whose law counts the other's charge, an equilibrium face inside a flat
210
+ stretch, a contact's law read by its change.
211
+
212
+ A slip that gives the right answer by another route isn't a gap; what the run found that was,
213
+ was one wrong answer the sweep sees and the suite didn't, and a test that had never looked at
214
+ two of the paths it claimed.
177
215
 
178
216
  ## Hard cases
179
217
 
package/llms.txt CHANGED
@@ -30,9 +30,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
30
30
  From a CDN, pin the version you tested against:
31
31
 
32
32
  ```js nocheck
33
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/index.js';
34
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/kit.js';
35
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/plot.js';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/plot.js';
36
36
  ```
37
37
 
38
38
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -219,9 +219,9 @@ a CDN needs a long virtual-time budget, 30 s.)
219
219
  <span id="readout"></span>
220
220
  <div id="figure"></div>
221
221
  <script type="module">
222
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/index.js';
223
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/kit.js';
224
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/plot.js';
222
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js';
223
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/kit.js';
224
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/plot.js';
225
225
 
226
226
  const def = build({
227
227
  T: 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "description": "Dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for semiconductors, electrochemistry and nerves, fast enough for live web demos",
5
5
  "type": "module",
6
6
  "exports": {
@@ -34,6 +34,7 @@
34
34
  "smoke": "node tools/smoke.js",
35
35
  "shots": "node tools/shots.js",
36
36
  "lawsweep": "node tools/lawsweep.js",
37
+ "fingerprint": "node tools/fingerprint.js",
37
38
  "prepublishOnly": "npm test && npm run types",
38
39
  "types": "rm -rf types && npx -y -p typescript@5 tsc --allowJs --declaration --emitDeclarationOnly --target es2022 --module nodenext --moduleResolution nodenext --outDir types src/index.js src/kit.js src/plot.js"
39
40
  },
package/src/lawRows.js CHANGED
@@ -349,6 +349,8 @@ function lawAmount(s, cs) {
349
349
  cs.w[len++] = -f * cap * s.VT;
350
350
  });
351
351
  }
352
+ // (A typed array drops writes past its end without a word: the law's derivative would lose them.)
353
+ if (len > cs.idx.length) throw new Error(`internal: a law's derivative needs ${len} entries, its workspace holds ${cs.idx.length}`);
352
354
  cs.len = len;
353
355
  return { amount, reference, dV, gross }; // (dV by terminal)
354
356
  }
package/src/solver.js CHANGED
@@ -1152,6 +1152,53 @@ export class Solver {
1152
1152
  }
1153
1153
  }
1154
1154
  }
1155
+ // Two strictly neutral regions meeting at a face whose links hold ions in equilibrium: where
1156
+ // c0 leaves the two sides out of it (a different fixed charge or immobile ion on each, so
1157
+ // c₊c₋ differs across it), the face reaches it at t = 0⁺, before anything diffuses, and a
1158
+ // first step made to take that jump met an exactly singular pivot. Start there: the ions the
1159
+ // face passes shared between its two nodes in equilibrium (μ̄ equal across it), each one's
1160
+ // amount over the two nodes kept, everything else as c0 has it. The one unknown is the jump
1161
+ // in φ̂ across it (a Donnan potential), set by neutrality on one side; the other's follows,
1162
+ // the two nodes' charge summing to what it was, zero.
1163
+ for (let f = 0; f < interfaces.length; f++) {
1164
+ const itf = interfaces[f], mL = materials[regions[f].material], mR = materials[regions[f + 1].material];
1165
+ const strict = (m) => m.epsr === 0 && !m.conductor && !m.phiFree && m.ideal;
1166
+ if (itf.phi.type !== 'neutral' || !strict(mL) || !strict(mR)) continue;
1167
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1], bL = this.blockOfNode[gL] * M, bR = this.blockOfNode[gR] * M;
1168
+ const pass = [];
1169
+ for (let i = 0; i < n; i++) if (itf.links[i].type === 'equilibrium' && mL.present[i] && mR.present[i]) pass.push(i);
1170
+ if (!pass.some((i) => z[i] !== 0)) continue;
1171
+ const RT = model.RT, vL = grid.vol[gL], vR = grid.vol[gR];
1172
+ const conc = (m, b, i) => m.cRef[i] * Math.exp(u[b + 1 + i] - m.mu0[i] / RT - z[i] * u[b]);
1173
+ const amount = pass.map((i) => vL * conc(mL, bL, i) + vR * conc(mR, bR, i));
1174
+ let qOther = regions[f].fixedCharge / FARADAY; // (the left node's charge from all it doesn't share)
1175
+ for (let i = 0; i < n; i++) if (mL.present[i] && !pass.includes(i) && z[i] !== 0) qOther += z[i] * conc(mL, bL, i);
1176
+ // At a jump D = φ̂_R − φ̂_L: c_L/c_R = (cRef_L/cRef_R)·exp((μ°_R − μ°_L)/RT + z D), the amount fixing both.
1177
+ const split = (D) =>
1178
+ pass.map((i, j) => {
1179
+ const K = (mL.cRef[i] / mR.cRef[i]) * Math.exp((mR.mu0[i] - mL.mu0[i]) / RT + z[i] * D), cR = amount[j] / (vL * K + vR);
1180
+ return [K * cR, cR];
1181
+ });
1182
+ const qL = (D) => split(D).reduce((q, [cL], j) => q + z[pass[j]] * cL, qOther); // (increasing in D)
1183
+ const D0 = u[bR] - u[bL];
1184
+ let lo = D0 - 1, hi = D0 + 1;
1185
+ while (qL(lo) > 0 && lo > D0 - 1e3) lo -= 2 * (hi - lo);
1186
+ while (qL(hi) < 0 && hi < D0 + 1e3) hi += 2 * (hi - lo);
1187
+ if (!(qL(lo) <= 0 && qL(hi) >= 0)) continue; // (nothing the face shares can balance it: leave c0)
1188
+ for (let it = 0; it < 200 && hi - lo > 1e-13 * Math.max(1, Math.abs(D0)); it++) {
1189
+ const m = 0.5 * (lo + hi);
1190
+ if (qL(m) < 0) lo = m;
1191
+ else hi = m;
1192
+ }
1193
+ const D = 0.5 * (lo + hi), cs = split(D);
1194
+ const keep = Float64Array.from({ length: n }, (_, i) => (mR.present[i] ? conc(mR, bR, i) : 0)); // (the right node's, before φ̂ moves)
1195
+ u[bR] = u[bL] + D;
1196
+ for (let i = 0; i < n; i++) if (mR.present[i] && !pass.includes(i)) u[bR + 1 + i] = Math.log(keep[i] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
1197
+ pass.forEach((i, j) => {
1198
+ u[bL + 1 + i] = Math.log(cs[j][0] / mL.cRef[i]) + mL.mu0[i] / RT + z[i] * u[bL];
1199
+ u[bR + 1 + i] = Math.log(cs[j][1] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
1200
+ });
1201
+ }
1155
1202
  // An insertion host's φ is a gauge: c0 sets only its neutral combination's level, not how that
1156
1203
  // splits between its ion and its electrons. Start its ion level with the same ion's beside it
1157
1204
  // across a face (the equilibrium a link or a transfer reaction heads for), so its electrons,