Two physics bugs the tests caught before a user did
jungl runs a year of sunlight through a model of your home. The sun part is exact, the room part is a model, and both were confidently wrong on day one. Here is what the tests assert, and why a deterministic core is what makes them possible.

jungl is a plant-care product built on one claim: where the sun falls in your home, hour by hour and month by month, decides whether a plant lives there. The pipeline is short. Solar position from latitude, longitude, date and time. A geometric model of light through a window into a room. A per-plant verdict from the light that reaches its spot. Every sentence the product says resolves down to those three stages.
The first stage is arithmetic and has no model near it. `solar.ts` implements the NOAA solar position algorithm in pure TypeScript: no I/O, no clock, no randomness. The file header states two rules. No model, ever, because asking a language model for a solar position converts an exact number into a plausible one. And no side effects, so every result is reproducible: same home, same date, same answer, so a test can say what the answer has to be.
Bug one: east and west, swapped
Solar azimuth comes out of an `acos`, and `acos` discards sign. It tells you how far the sun is from due south, and nothing about whether that is the morning side or the afternoon side. The hour angle carries that information, and a branch has to put it back. Our first branch put the four o'clock sun in the morning slot. Every number it produced was plausible: elevation right, azimuth a real compass bearing, the day symmetric about noon. It would have inverted every morning-sun claim in the product.
const azFromSouth = Math.acos(cosAz) * DEG;
// acos discards the sign, so it cannot tell morning from afternoon on its
// own; the hour angle carries that. Getting this branch wrong swaps east
// and west, which would have inverted every "morning sun" claim in the
// product while still producing plausible-looking numbers.
azimuth =
hourAngle > 0 ? (azFromSouth + 180) % 360 : (540 - azFromSouth) % 360;What caught it was a test that asserts a physical identity rather than a remembered figure. The suite header says why: a test that hardcodes the elevation in Boston at 14:32 as 41.7 degrees is only as trustworthy as whoever typed 41.7, and if that came from memory the test is decoration. So the solar tests assert things the solar system itself guarantees, and anyone can check against first principles.
it('gives a noon elevation of 90 minus latitude at the equinox', () => {
for (const latitude of [0, 23, 40, 51.5, 60]) {
const noon = solarNoon({ latitude, longitude: 0 }, 2026, 3, 20);
expect(noon.elevation).toBeCloseTo(90 - latitude, 0);
}
});
it('sends the sun east in the morning and west in the afternoon', () => {
const noon = solarNoon(at, 2026, 5, 10);
const morning = solarPosition(at, noon.millis - 4 * 3_600_000);
const afternoon = solarPosition(at, noon.millis + 4 * 3_600_000);
expect(morning.azimuth).toBeLessThan(180);
expect(afternoon.azimuth).toBeGreaterThan(180);
});
it('keeps the sun below the horizon all day inside the Arctic winter', () => {
for (let minute = 0; minute < 1440; minute += 10) {
const pos = solarPosition({ latitude: 78, longitude: 15 },
UTC(2026, 12, 21) + minute * 60_000);
expect(pos.elevation).toBeLessThan(0);
}
});The same file asserts that declination peaks at the obliquity in June, that the equation of time stays inside about plus or minus 17 minutes and changes sign four times a year, and that the day is very nearly symmetric about solar noon. That last one is deliberately 'nearly': declination drifts through the day, so an afternoon is never quite the mirror of its morning, and the test allows the drift rather than asserting an idealisation the sky does not obey.
Bug two: a window is not a point of light
Direct beam is only half of what a room receives. The other half is diffuse skylight, and the first version of the model did not have it at all. Adding it, we reached for the obvious expression for how much sky a spot sees through a window: area over distance squared, reduced by obliquity and by whatever stands outside the glass, normalised against a hemisphere. That is the far-field formula. It treats the window as a point source and is only valid a long way from it.
A plant standing 80 cm inside a four square metre bay window came back with a sky factor of 0.84. Do the arithmetic on the raw form: four over 0.64 is 6.25 steradians, and a whole hemisphere is 2π, about 6.28. The formula was handing a houseplant nearly the entire sky through one pane of glass. The scene on the landing page took that number and declared a perfectly healthy fiddle leaf fig scorched. Nothing crashed. The render was beautiful.
// first version: far field, valid only at distance
total += (area * obliquity * openness) / distanceSq;
// second version: solid angle of an equal-area disc. Agrees with the
// far-field form at distance and saturates at 2*pi at the glass.
const radius = Math.sqrt(area / Math.PI);
const solidAngle = 2 * Math.PI * (1 - distance / Math.sqrt(distanceSq + radius * radius));
// current: exact on-axis solid angle of the rectangle, then off-axis
// by the cosine on the window's normal. The disc overstated wide, short
// windows at short range by about half.
const onAxis = 4 * Math.atan((a * b) / (dist * Math.sqrt(a * a + b * b + dist * dist)));
const solidAngle = onAxis * cosNormal;The disc was the fix we shipped that night. A later physics review found that the disc overstates wide, short windows at short range by roughly 50 percent, and that the whole term had been computed in plan, with no vertical leg: a clerestory with its sill at 2.2 m and a low window with its sill at 0.3 m returned the identical factor, 0.399, for a spot 30 cm out on the floor. The exact rectangle and the full 3D vector are both in the code now, and the review's own assertions are permanent tests.
describe('review 3, finding 1: the diffuse term dropped the vertical geometry', () => {
const hi = { id: 'w', x: 0, z: 0, bearing: 0, width: 1, height: 0.5, sill: 2.2 };
const lo = { ...hi, sill: 0.3 };
const sp = { id: 's', x: 0, z: 0.3 };
it('a clerestory 2.2m up subtends almost nothing at a floor spot 0.3m out', () => {
// Was 0.399 - identical to a low window, because distance was taken in plan.
expect(skyViewFactor(sp, [hi])).toBeLessThan(0.02);
});
});Why the diffuse term is not optional
It would be tempting, after a bug like that, to drop the diffuse term and ship direct hours alone. You cannot, because direct beam alone reports a north-facing window as receiving nothing in any month of the year. North light is the steadiest bright-indirect light in a house. A product that only counts beam hours labels every north-facing room as dark and every plant in it as failing, and does so with perfect arithmetic. The test that pins this states the old model's verdict and the corrected one side by side.
it('gives a north-facing window real light, which direct beam alone never does', () => {
const spot = { id: 'n', x: 0, z: INDOORS_OF_NORTH_WINDOW };
const direct = dailyDirectHours(spot, [northWindow], LONDON, day(2026, 6, 21));
const sky = skyViewFactor(spot, [northWindow]);
expect(direct).toBe(0); // the old model's verdict: nothing, all year
expect(sky).toBeGreaterThan(0); // the corrected one: steady indirect light
});The effective light a spot receives is then direct hours plus a weighted sky factor. The weight, `DIFFUSE_WEIGHT = 14`, is a convention, chosen so a large unobstructed window a metre or two away lands in bright-indirect with no beam at all, which is what any plant owner would recognise. The code says so in a comment next to the constant, and the product is required to describe these outputs as a simulation of the room, never as a reading taken in it.

What the tests actually assert
Almost none of the tests in the sun directory compare against a stored figure. They assert shapes, and the shapes come in four kinds.
- Identities: equinox noon stands at 90 minus latitude; the noon sun is due south from the northern mid-latitudes and due north from the southern; a skylight treated as facing straight up scores one with the sun overhead.
- Symmetry and its limits: the day is nearly symmetric about solar noon, with an allowance for the declination drift that makes it not quite so; solar noon shifts four minutes per degree of longitude.
- Monotonicity: the sky factor falls off with distance, is larger for a bigger window at the same distance, is reduced by obstruction in proportion; a lower sun reaches deeper into the room than a higher one.
- Conservation-style bounds: a spot never sees more than a full hemisphere, however close it stands; a day never reports more direct hours than it contains; refraction never lowers the sun; the incidence factor is never negative.
describe('sky view factor near-field (regression)', () => {
const bay: Window = { id: 'bay', x: 0, z: 0, bearing: 180, width: 2.4, height: 1.7, sill: 0.6 };
it('never reports more sky than exists, however close the spot stands', () => {
for (const d of [0.25, 0.5, 0.8, 1.2, 2, 4, 8]) {
const f = skyViewFactor({ id: 's', x: 0, z: -d }, [bay]);
expect(f).toBeGreaterThanOrEqual(0);
expect(f).toBeLessThanOrEqual(1);
}
});
it('stays below half a hemisphere at a normal standing distance from a bay window', () => {
expect(skyViewFactor({ id: 's', x: 0, z: -0.8 }, [bay])).toBeLessThan(0.5);
});
});Tests of this kind are only possible because the function under test is pure. There is no fixture to record, no clock to freeze, no network to stub. A deterministic model either satisfies an identity or does not, every run.
The tests we got wrong
Several assertions had to be rewritten because they were asserting a belief rather than physics, and the model was right. The near-field regression originally demanded that the sky factor decrease monotonically from 50 cm outward. Once the factor was cosine-weighted for a horizontal leaf, a spot pressed against the wall looks up at glass that sits high and to the side of it, and receives less than a spot a metre back. The factor peaks near a metre, and the test now asserts monotone from the peak, with the near-wall dip bounded rather than a discontinuity.
A generated-homes test asserted that every home darkens in December. Winter sun is lower, so it drives its beam deeper into a room; a deep south-facing plan can score higher in December than June averaged over the floor while every window is plainly darker. Assert at the window. Another test expected a south-window spot one metre inside to receive December beam; at London's winter noon the beam lands more than three metres deep, and the test was rewritten as the identity it should have been: December deep beats shallow, June shallow beats deep. A test that encodes the wrong physics is asserting the bug.
The hero that looked right and lied
The 3D scene on the landing page is drawn by the same functions the product runs, and for a while that was the problem: it looked right, so nobody asked. The hero's centrepiece fiddle had been placed by eye, 40 cm beyond where the June beam actually lands, and sat in shade reporting itself as declining on a page whose whole argument is that placement decides a plant's fate. Printing the computed sun-patch corners and putting the plants inside them fixed it.
Then the walls. The window-patch model projected a beam onto the floor and asked whether a spot fell inside it. It never asked what stood between. A kitchen three rooms back in a Victorian conversion, behind two solid partitions, collected direct sun in December. The fix traces from the spot back toward the sun; where the trace crosses an interior wall in plan, the beam's height there is compared to the wall's top, because a steep beam passes over a partition a low one hits. The diffuse term needed the same lesson separately: a window behind a full-height wall had been contributing a sky factor of 0.064 to a spot that could not see it, and now contributes zero.
Exterior walls were a separate omission. The drawn sun pool was clipped by interior partitions only, so at low sun the beam overshot the room's own far wall and spilled outside the building. The first fix dropped every pool cell whose centre fell outside the footprint, and that was still wrong: at low sun a cell is more than a metre deep, so a cell whose centre was inside hung half its length past the wall. The current clip clamps each cell's corners to its room, with a test at 8 degrees of elevation that fails on centre-only clipping. Floors were next: until rooms and windows carried a storey, a landing window painted a pool on the hall floor below it.
it('blocks a low sun coming from beyond the wall', () => {
expect(occludedByInterior({ id: 's', x: -2, z: 0 }, { elevation: 15, azimuth: 90 }, [wall])).toBe(true);
});
it('lets a high sun pass over a finite wall', () => {
// beam clears 1.5m well before reaching the wall at 2m away (tan 60 * 2 = 3.46m)
expect(occludedByInterior({ id: 's', x: -2, z: 0 }, { elevation: 60, azimuth: 90 }, [wall])).toBe(false);
});
describe('pool cells cannot overhang their wall', () => {
it('clamps every drawn vertex inside the footprint, not just the cell centre', ...The model is code with an authority
jungl also has a language model in it. It drafts geometry from a description of your home and explains verdicts in plain words. The rule for where it stops is a test rather than a paragraph. Every question the product can ask is declared in a closed registry with an authority: `simulation`, `model`, or `either`. Solar position, direct sun hours and sky view factor are `simulation`. Registering a model tool against a simulation-authority question throws at import, so a build that tried it never starts, and a runtime call site that tries to ask the model one of those questions is refused by name.
'direct-sun-hours': {
asks: 'How many hours of direct beam reach this spot on this day?',
authority: 'simulation',
// this is the single number the product must never let a model produce.
},
if (spec.authority === 'simulation' && model.length > 0) {
throw new AgencyViolation(
`"${question}" is a simulation-authority question: the arithmetic owns it. ` +
'The model may READ a light number and explain it; it may never produce one.',
);
}This is the general lesson for anyone putting a physical or statistical model behind an AI product. The model has to be code, with an authority and tests that assert its identities, and the language model has to be structurally unable to improvise the number the code exists to compute. Both bugs above produced plausible output. A language model asked for the same quantities would also produce plausible output, and no test could catch it, because there would be no function to test.
What this does and does not establish
The solar core is exact to well under a degree and three independent physics reviews agreed it was clean; the room model is a geometric approximation of light through a hole in a wall and says so. `DIFFUSE_WEIGHT` and the light-class thresholds are conventions, waiting to be corrected against real rooms. There is no sky radiance distribution, no ground bounce, no inter-reflection. Glass transmission landed later and compressed the indoor scale enough that the thresholds had to be re-anchored. The tests prove the model obeys the physics it claims to model, at the points we thought to assert. They do not prove a fiddle leaf fig will thrive. That claim is labelled a preview until a calibration loop against real plants says otherwise.
More from the notebook →