driftlet 1.2.1 → 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.1/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
 
@@ -300,7 +300,7 @@ npm run shots # retake the demos' screenshots, each caught at a set state (to
300
300
 
301
301
  Design notes and plans: [reliability](docs/reliability.md), [numerics](docs/numerics.md), [conventions](docs/conventions.md),
302
302
  [statistics](docs/statistics.md), [the kit](docs/kit.md), [reading level diagrams](docs/visualization.md),
303
- [data](docs/data.md), [roadmap](ROADMAP.md).
303
+ [data](docs/data.md), [internals](docs/internals.md) (the source, module by module), [roadmap](ROADMAP.md).
304
304
 
305
305
  ## Credits
306
306
 
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
@@ -0,0 +1,93 @@
1
+ # Internals
2
+
3
+ A map of the source for anyone reading or reviewing it: what each module is for, the layout of
4
+ the solver's state that the rest assumes, and which modules state a contract. The user-facing
5
+ reference is the other docs; this one is about the code.
6
+
7
+ ## The rule: a module states its contract
8
+
9
+ A module whose correctness can't be seen from its call sites says at its head what it reads,
10
+ what it returns or writes, and what must hold of the result, so that it can be checked on its
11
+ own, without the rest of the library in mind. Where the contract and the code disagree, one of
12
+ them is wrong: fix both, with a test, in the same commit.
13
+
14
+ | Module | Contract |
15
+ | --- | --- |
16
+ | `src/laws.js` | yes: the steady system's conservation laws |
17
+ | `src/lawRows.js` | yes: the laws written into the Newton system, and the bordered solve |
18
+ | `src/difference.js` | header: the dilute kernels in difference form |
19
+ | `src/blockTridiagonal.js` | header: the block Thomas solver |
20
+ | `src/statistics.js` | header: each model's `evaluate` / `invert` |
21
+ | `src/solver.js` | not yet (below) |
22
+
23
+ ## From a definition to a solution
24
+
25
+ - `src/device.js`: `normalizeDevice(def)` validates a plain definition and returns the
26
+ normalised `model` (species, materials, regions, interfaces, contacts, ports, terminals, the
27
+ grid from `src/grid.js`). Every later stage reads the model and never the definition.
28
+ - `src/index.js`: `Device`, the public object. It holds the model, builds a `Solver` on first
29
+ use, and turns solver states into `Solution`s (`src/solution.js`).
30
+ - `src/solver.js`: `Solver`, the numerical core (below).
31
+ - The kit (`src/kit.js` and what it re-exports: stack, data, sources, channels, checks,
32
+ describe, traces, live, recorder, particles, polarization, junction, devices) works on
33
+ definitions and solutions only, never on a solver's internals. `src/plot.js` draws
34
+ solutions.
35
+
36
+ ## The solver's state
37
+
38
+ - **Blocks.** One per grid node and one per face between regions: node g is block
39
+ `blockOfNode[g]`, face f's flux block is `blockOfFace[f]`, in order along x.
40
+ - **Slots.** Each block has `M = 1 + n + extra` slots: slot 0 is φ̂ (at a face its
41
+ displacement D, at a metal's node its carriers' segment flux J), slots `1 … n` the species'
42
+ η = μ̄/RT, then a face's reaction rates and gates' fractions, or at an electrode port's nodes
43
+ its surface's coverages (as η) and a membrane port's gates. Row and unknown `o = b·M + slot`.
44
+ - **Compact indices.** Only active slots enter the linear system: `loc[o]` is a slot's place in
45
+ its block (−1: inactive), `rix[o]` its index in compact vectors, with `rix = N` (the sink) for
46
+ inactive ones. `res`, `delta` and the Jacobian (`sys`, a `BlockTridiagonal`) are compact.
47
+ - **Unknowns** are `u + uLo`, a double-double, so that differences of η keep their digits; the
48
+ Jacobian and solves are plain doubles. Concentrations `c[g·n + i]` and coverages
49
+ `th[g·nSurf + q]` follow from `u` (`computeConcentrations`).
50
+ - **Terminals:** 0 the left contact, 1 the right, `2 + k` port k. A floating terminal (driven by
51
+ a current, or behind a resistance) has its voltage as an extra unknown and its circuit row
52
+ beside the block system, solved by bordering (`solveBordered` in `src/lawRows.js`: `termB`
53
+ its column, `termC` its row, `termDI` and `termDX` its own and other terminals' ∂/∂V).
54
+ - **Stretches:** each connected run of regions where a species is present
55
+ (`_findStretches`), the unit that conservation counts in.
56
+
57
+ ## The solver's sections
58
+
59
+ By method, in file order:
60
+
61
+ - **Setup:** the constructor (layout, per-node data, φ-undefined regions, terminals),
62
+ `_activeSlots`, `_findStretches` (stretches, flat stretches), `_laws` and `_structure` (the
63
+ laws, from `src/laws.js`), `_directSteady`.
64
+ - **Applying the laws** is `src/lawRows.js` (`captureLaws`, `captureLocal`, `snapFlat`,
65
+ `applyLaws`, `solveBordered`), called from `assemble`, `newton`, `_refine` and the steady
66
+ solves; `_replaceRow` and `_j`, which it uses, stay in the solver.
67
+ - **Starting state:** `initFromComposition`, `_surfaceStart`.
68
+ - **Statistics and assembly:** `computeConcentrations`, `_nodeStatistics`, `assemble` and the
69
+ kernels it calls (nodes, segments, faces, bulk reactions, ports, contacts, charge rows),
70
+ `_assembleDifference` (with `src/difference.js`).
71
+ - **Newton:** `newton`, `_refine`, `_equilibrate`, `_kirchhoff`, `_noteConditioning`.
72
+ - **Time steps:** `advance`, `step`, `integrate` (BDF with error control, breakpoints from the
73
+ sources), `_predict`, `_errorNorm`, `_snapshot` / `_restore`.
74
+ - **Small signal:** `impedance` and the helpers after it.
75
+ - **Bookkeeping:** `segmentFluxes`, `faceOneWay`, `_assembleBookkeeping`, the contacts' intake.
76
+ - **Steady solves:** `solveSteady`, `_steadyFromHere`, the continuations (floating, generation,
77
+ a terminal's level), `_steadySteps`.
78
+
79
+ The rest of `src/solver.js` is to be split along these lines, each piece with its contract.
80
+
81
+ ## Reviewing one module
82
+
83
+ A review of one module reads its contract, its code and its tests, and nothing else unless the
84
+ contract points there. Each claim cites `file:line` with the line quoted, so it can be checked
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. 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.
package/docs/numerics.md CHANGED
@@ -730,18 +730,29 @@ the terms that cancel:
730
730
  port or contact at no current that passes K⁺ keeps $`n_K + Q/F`$ (a contact's Q its
731
731
  displacement $`D_\text{in}`$), and two floating electrodes sharing a closed Fe³⁺ keep the iron
732
732
  with both their charges, the law in one's circuit row moving with the other's voltage too.
733
+ A charge is kept only in a circuit row, once: the first combination through it takes one,
734
+ and the rest have its multiple taken off (a capacitor passing K⁺ under K⁺ + L⁻ = KL keeps
735
+ $`n_K - n_L + D/F`$ there, and $`n_L + n_{KL}`$ in a balance row).
733
736
  At any other current such a terminal only stores what comes in, which has no steady state,
734
737
  and the solve says so at once.
735
738
  - an **immobile** combination (trap states X⁰ and X⁻ under e⁻ + X⁰ = X⁻, with D = 0) keeps it
736
739
  node by node, since nothing carries it anywhere, as do an electrode's surface species
737
740
  (adsorbates that only turn into each other keep their total on every site), its row local,
738
741
  one per node in its own block, a coverage counted as the sites it fills per volume,
739
- $`\Gamma a \theta`$, beside the concentrations. (The null space is taken with the immobile and
742
+ $`\Gamma a \theta`$, beside the concentrations. Each node's rows are chosen by elimination
743
+ over the slots there, as below. Immobile means D = 0 in one region with no terminal feeding
744
+ it: a species linked across a face is carried between the face's two nodes, and one a
745
+ port links is carried between the port's nodes, kept with the port's charge in its circuit
746
+ row. (The null space is taken with the immobile and
740
747
  surface columns first, so that these come out apart from the combinations that move.) A combination of immobile
741
748
  stretches with a moving one (the electrons the traps hold, $`e^- - X^0`$, in a closed film) is
742
749
  an amount like any other, its immobile parts summed over their nodes;
743
750
  - a **floating metal** keeps the charge on its faces, its sheets' displacement.
744
751
 
752
+ A combination that none of these rows can keep (through a stretch immobile in part only, a
753
+ face reaction joining two immobile stretches at a face, a charge with no circuit row left) is
754
+ set aside, and the steady solve then goes by time steps, which keep every amount themselves.
755
+
745
756
  A closed stretch keeps the amount it was given (its `c0`, or what it held when `set()`
746
757
  closed it); a combination behind a terminal at no current keeps what the state holds when the
747
758
  solve begins. The amount's row is dense, so it's bordered (see [terminals](#terminals)): the
@@ -146,14 +146,17 @@ $`J \cdot v`$.
146
146
  A steady solve replaces one redundant equation per conserved combination with the combination's
147
147
  amount (see [numerics](numerics.md#the-steady-system)). A combination left out, or weighed wrongly,
148
148
  gives a state that converges and is steady but holds the wrong amounts, which only a transient
149
- from the same start shows. `npm run lawsweep` builds 810 small devices from the features at the
149
+ from the same start shows. `npm run lawsweep` builds 870 small devices in two families. The first, 810, combine the features at the
150
150
  edges of that bookkeeping: a floating, resistive or held electrode, with or without a
151
151
  capacitance, with adsorbates or an immobile species that adsorbs; a second floating electrode
152
152
  or a membrane capacitor; a capacitive or ion-passing contact at no current; a face that blocks
153
153
  an ion or makes an immobile species. Each is solved directly and run as a transient for 1e6 s,
154
154
  and where the transient converged, finished and moves no further over 100× as long, the two
155
155
  must agree on every species' amount, every surface's sites and every floating terminal's
156
- voltage.
156
+ voltage. The second, 60, put an immobile S⁺ beside what carries it (a face that passes it, a port
157
+ at I = 0 that links it, an electrode it adsorbs on) with a capacitive contact passing K⁺ (at
158
+ I = 0 or behind a resistance) and K⁺'s complex with a closed L⁻, each where a cold review of
159
+ `src/laws.js` found a law kept wrongly.
157
160
 
158
161
  Every reaction starts at its equilibrium. On 1.2.1, 675 devices are built (135 combinations
159
162
  repeat others); 579 agree, and 83 transients fail to converge. In the other 13 the transient
@@ -161,8 +164,54 @@ is the one that's off: it loses a conserved K⁺ total in proportion to its tole
161
164
  at tol 1e-9) when a capacitive contact, a membrane capacitor and a held electrode share the
162
165
  device, where the steady solve keeps it to round-off. The sweep found its first law the day it
163
166
  was written (an immobile species adsorbing on an electrode over part of its region); Gemini 4
164
- Argon's review found nine more by hand (one through a device it suggested), each now in
165
- `test/laws.test.js`.
167
+ Argon's review found nine more by hand (one through a device it suggested), and a cold review
168
+ of `src/laws.js` against its contract alone five more (an immobile species linked across a face,
169
+ or by a port; two node-by-node laws sharing a row; a capacitive contact's charge in a second law,
170
+ and in a time step's reading of its current), none of them in the sweep's devices. Each is now in
171
+ `test/laws.test.js`. On the code before those fixes the second family has 14 devices that
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
+ 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,
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.
166
215
 
167
216
  ## Hard cases
168
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.1/src/index.js';
34
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/kit.js';
35
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/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.1/src/index.js';
223
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/src/kit.js';
224
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.1/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.1",
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
  },