Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
faf8500fa5 feat(samples): add "Bathtub with an overflow" companion
The bathtub given a hard ceiling: a flat-out constant tap and an overflow spillway
that carries whatever rises past the brim into a second Stock, the floor. Water
climbs in a straight line then plateaus just above capacity while the floor fills —
an equilibrium from the spill, not from the inflow easing off. The gallery's one
declared ceiling, a counterpoint to the emergent one in "Limits to growth".
2026-06-21 18:31:39 +02:00
Julien Calixte
f691b99ca5 feat(simulation): add the overflow rule, a one-sided gap for hard ceilings
A fourth Rule kind: overflow = max(0, factor × (level − threshold)). It stays
shut until its `+` level passes its `−` threshold, so an outflow on it spills only
the excess — a spillway / hard ceiling the smooth rules can't draw.

Gap can't stand in for it: it's signed and bidirectional (coffee cooling relies on
that), so below the threshold a gap outflow runs backwards. Clamping at zero is the
whole point, and it can't be applied globally without breaking gap. This is the
additive vocabulary growth ADR-0004 anticipates — a new kind in the union plus a
case in the evaluator — alongside touch-ups to the rule validator and the inspector.
2026-06-21 18:31:39 +02:00
5 changed files with 109 additions and 1 deletions

View File

@@ -115,6 +115,8 @@ const RULE_HINT: Record<Rule["kind"], string> = {
constant: "A fixed number — no inputs.",
proportional: "rate = factor × its “+” inputs.",
gap: "rate = factor × (level target): the “+” input is the level, the “−” the target.",
overflow:
"rate = max(0, factor × (level threshold)): spills only once the “+” level passes the “−” threshold.",
}
</script>
@@ -167,6 +169,7 @@ const RULE_HINT: Record<Rule["kind"], string> = {
<option value="constant">Constant</option>
<option value="proportional">Proportional</option>
<option value="gap">Gap</option>
<option value="overflow">Overflow</option>
</select>
</label>

View File

@@ -55,7 +55,8 @@ function isPolarity(value: unknown): value is Polarity {
function isRule(value: unknown): value is Rule {
if (!isObject(value)) return false
if (value.kind === "constant") return isFiniteNumber(value.value)
if (value.kind === "proportional" || value.kind === "gap") return isFiniteNumber(value.factor)
if (value.kind === "proportional" || value.kind === "gap" || value.kind === "overflow")
return isFiniteNumber(value.factor)
return false
}

View File

@@ -54,6 +54,16 @@
* Expertise that holds quality up, so the team leans on AI
* harder and Technical debt spirals: addiction, not a fix.
*
* And a mechanical coda — the gallery's one *hard* ceiling, to contrast the emergent
* ones (above all "Limits to growth"):
*
* 15. Bathtub with an overflow — the tap runs flat out (a Constant inflow that never
* reads the level) and a spillway carries whatever rises
* past the brim into a second Stock, the floor. The only
* model on the `overflow` rule — a one-sided Gap, the
* threshold the smooth rules can't draw — so here the
* ceiling is *declared*, not emergent.
*
* These are plain data built from the same tested constructors the store uses
* (factory.ts), so every sample is a valid Model by construction. `build()`
* mints fresh ids on each call, so loading a sample twice never collides.
@@ -1343,6 +1353,78 @@ function aiDeskillingSpiral(): Model {
)
}
/**
* Bathtub with an overflow — the bathtub given a hard ceiling the honest way. The tap
* keeps running flat out (filling stays a Constant; it never reads the level), and a
* spillway carries off whatever rises past the brim: overflow = max(0, factor ×
* (Water capacity)). That `overflow` rule is a one-sided Gap — shut until Water passes
* the `` threshold (capacity), then draining the excess into a second Stock, the Floor.
* The loop Water → overflow → Water carries one `` (the outflow) → Balancing, so Water
* climbs in a straight line, then plateaus just above the brim — a weir needs a little
* head of water to discharge — while the Floor goes on filling. The equilibrium comes
* from the *spill*, not from the inflow easing off: the counterpoint to a float valve,
* which throttles the inflow instead, and to "Limits to growth", whose ceiling emerges.
*/
function bathtubOverflow(): Model {
const source = makeCloud({ x: -280, y: 0 })
const water = makeStock({ x: 0, y: 0 }, "Water")
water.initialValue = 20
water.unit = "L"
water.description =
"The water level — the tap fills it in a straight line, then it holds just above the brim as the overflow carries off everything extra."
const filling = makeFlow(
midpoint(source.position, water.position),
"filling",
source.id,
water.id,
)
// A constant tap — it never reads the level, so it keeps pouring even at the brim.
// The ceiling comes from the overflow below, not from the inflow easing off.
filling.rule = { kind: "constant", value: 5 }
filling.description =
"A constant 5 L/step from the tap (the Source cloud), running flat out. It never throttles — the ceiling is the overflow's doing, not the tap's."
const floor = makeStock({ x: 0, y: 240 }, "Floor")
floor.initialValue = 0
floor.unit = "L"
floor.description =
"Water spilled onto the floor — the second Stock the overflow collects in. Empty until the tub brims, then it fills at the spill rate."
const overflow = makeFlow(
midpoint(water.position, floor.position),
"overflow",
water.id,
floor.id,
)
// overflow = max(0, 1 × (Water capacity)): shut below the brim, spilling the excess
// above it. factor 1 at dt 1 settles in a step without oscillating; the level rides a
// touch over capacity — the head a spillway needs to discharge its inflow.
overflow.rule = { kind: "overflow", factor: 1 }
overflow.description =
"The spillway, max(0, Water capacity): nothing while the tub is below the brim, then it carries off the excess — the Balancing drain that holds the level."
const capacity = makeConverter({ x: 240, y: 120 }, "capacity")
capacity.rule = { kind: "constant", value: 100 }
capacity.description =
"The tub's brim (a fixed 100 L) — the threshold the overflow opens above; the level holds just over it."
return model(
"Bathtub with an overflow",
[source, water, filling, floor, overflow, capacity],
[
link(
water,
overflow,
"+",
"Water is the level in the overflow rule: the higher it rises past the brim, the harder it spills. With the outflow, this closes the Balancing loop that holds the level.",
),
link(
capacity,
overflow,
"-",
"Capacity is the threshold the spill opens above (the input): a higher brim → less spill at the same level.",
),
],
{ start: 0, stop: 40, dt: 1 },
)
}
/** The gallery, ordered simplest first. */
export const SAMPLES: Sample[] = [
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
@@ -1411,4 +1493,9 @@ export const SAMPLES: Sample[] = [
blurb: "Leaning on AI to hold quality erodes the expertise that holds it: shifting the burden.",
build: aiDeskillingSpiral,
},
{
title: "Bathtub with an overflow",
blurb: "A flat-out tap and a spillway: the excess spills to a second Stock and the level holds.",
build: bathtubOverflow,
},
]

View File

@@ -107,6 +107,19 @@ function evalRule(rule: Rule, links: InformationLink[], valueOf: (id: string) =>
rule.factor * ((level ? valueOf(level.source) : 0) - (target ? valueOf(target.source) : 0))
)
}
case "overflow": {
// max(0, factor × (level threshold)): a one-sided gap. The `+` input is the
// level, the `` the threshold; it stays shut until the level passes it, so an
// overflow Flow spills only the excess. Clamping at 0 is what stops it running
// backwards below the threshold — gap can't, by design (it's bidirectional).
const level = links.find((link) => link.polarity === "+")
const threshold = links.find((link) => link.polarity === "-")
return Math.max(
0,
rule.factor *
((level ? valueOf(level.source) : 0) - (threshold ? valueOf(threshold.source) : 0)),
)
}
}
}

View File

@@ -42,11 +42,15 @@ export interface Position {
* (→ exponential growth/decay)
* - `gap` — `factor × (level target)`, where the `+` input is the
* level and the `` input the target. (→ goal-seeking)
* - `overflow` — `max(0, factor × (level threshold))`: a one-sided `gap`
* that only fires once the `+` level exceeds the ``
* threshold. (→ a spillway / hard ceiling)
*/
export type Rule =
| { kind: "constant"; value: number }
| { kind: "proportional"; factor: number }
| { kind: "gap"; factor: number }
| { kind: "overflow"; factor: number }
/**
* The run parameters for a simulation: integrate from `start` to `stop` in steps