Skip to content

Commit 0a7f959

Browse files
martex-devclaude
andcommitted
M32: an actor with somewhere to be
The people were a gif: a few pixels of path traced back and forth, forever, at the same speed whatever was happening. An actor now walks between the stations of its own room -- the room's own fixtures, chosen from its own stretch of floor -- stops at each one, leans into the work and turns to face the way it is going. Where a stretch of floor has nothing to work at the actor paces it rather than miming at a spot where there is nothing, because two stations a hand's width apart reads as a broken animation rather than as somebody busy. And the animation means something now. An arm that does not engage a room has nobody working in it, so switching arms changes who is at work and the actors in the rooms it leaves out stand at their posts. Everybody carries a nameplate, outside the group that mirrors so it stays the right way round. Also the two thin places: Review and the Oracle had kits written for a layout with four rows of depth and supplied too little to stand on a floor, and the plant hall's right-hand third read as the building running out. Two things it revealed: a frozen document clock looks exactly like a broken animation, and the nameplate went in under a class the dossier already used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8a86107 commit 0a7f959

7 files changed

Lines changed: 331 additions & 50 deletions

File tree

BUILD_PLAN.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
This is the executable plan derived from [`docs/`](docs/). The design documents say *what* to build and *why*; this says *in what order*, *with what acceptance test*, and *what changes because of the machine we're actually on*.
66

7-
**Status:** M0–M31 complete; v5 and v6 landed, v7 registered and not yet run. **V6's result does not adjudicate what v6 registered** — its treatment arm did not implement the mechanism it names; see M23. The live path is wired but unspent (mock-driven throughout; the first live run awaits an API key). M12's code-generation half is blocked on both a key and Docker. Nothing below is claimed as done until its acceptance criteria are green in CI.
7+
**Status:** M0–M32 complete; v5 and v6 landed, v7 registered and not yet run. **V6's result does not adjudicate what v6 registered** — its treatment arm did not implement the mechanism it names; see M23. The live path is wired but unspent (mock-driven throughout; the first live run awaits an API key). M12's code-generation half is blocked on both a key and Docker. Nothing below is claimed as done until its acceptance criteria are green in CI.
88

99
---
1010

@@ -1272,6 +1272,61 @@ it failed — it was the only thing in the suite that knew which way the camera
12721272

12731273
---
12741274

1275+
### M32 · An actor with somewhere to be ✅
1276+
Presentation only. `model.py`, `map.py` and `ledger.py` untouched.
1277+
1278+
**The people were a gif.** Each one traced a few pixels of path back and forth, forever, at the
1279+
same speed, whatever was happening. That is decoration, and decoration on a page whose whole claim
1280+
is that nothing on it is decorative. An actor now walks between the **stations of its own room**
1281+
the room's own fixtures, chosen from its own stretch of floor — stops at each one, leans into the
1282+
work, and turns to face the way it is going. Where a stretch of floor has nothing to work at, the
1283+
actor paces it rather than miming at a spot where there is nothing: two stations a hand's width
1284+
apart is a figure shuffling on the spot, which reads as a broken animation rather than as somebody
1285+
busy, so below a minimum spread the route falls back to walking.
1286+
1287+
**And the animation now means something.** An arm that does not engage a room has nobody working
1288+
in it, so switching arms changes who is at work — the actors in the rooms that arm leaves out
1289+
stand at their posts. That is the same fact the route, the opacity and the dossier already report,
1290+
said a fourth way, and it is the difference between a map that moves and a map that is showing
1291+
you a system.
1292+
1293+
**Everybody says who they are.** A nameplate rides above each figure with the role's own name on
1294+
it, outside the group that mirrors, so it stays the right way round when its owner turns.
1295+
1296+
**The two thin places are filled.** Review and The Oracle had kits written for a layout with four
1297+
rows of depth and supplied too few things to stand on a floor; both gained furniture. The plant
1298+
hall had three tanks and two exchangers clustered to the left, so its right-hand third read as the
1299+
building running out — a row of nine switchgear and skid units now runs the length of its deck.
1300+
1301+
#### What building it revealed was wrong
1302+
1303+
**1. A negative delay and a frozen clock look exactly like a broken animation.** Sampling the
1304+
actor's transform in the browser returned the same number sixteen times over eleven seconds. The
1305+
keyframes were right, `getAnimations()` said `running`, and the value never moved — because the
1306+
pane was not compositing, so the document timeline was not advancing at all. Driving
1307+
`animation.currentTime` by hand proved the motion in one step. Worth recording: when an animation
1308+
looks stuck, check the clock before the keyframes.
1309+
1310+
**2. `tag` was already taken.** The nameplate went in as `class="tag"`, which is also the class on
1311+
the dossier's backing chip — so the count of nameplates on the page was one more than the number
1312+
of people in the building. The test that caught it was counting a class name rather than a thing,
1313+
which is exactly why it caught it.
1314+
1315+
**Acceptance**
1316+
- Every test from M22 through M31 still passes; three that read the old motion path were rewritten
1317+
against what an actor does now.
1318+
- Every actor's stations are inside its own room, in order, and far enough apart that nobody
1319+
shuffles on the spot. Tested.
1320+
- Two actors in a room never cross. Tested.
1321+
- At least two thirds of actors are stationed at a real fixture rather than pacing, and no actor is
1322+
half at a station. Tested.
1323+
- An actor in a room this arm does not engage stands at its first station, with the animation off.
1324+
Tested against both the stylesheet and the script.
1325+
- Every actor carries its own name. Tested.
1326+
- Still one self-contained file; still refuses to build on input that does not verify.
1327+
1328+
---
1329+
12751330
## 2. Working agreement
12761331

12771332
- **`main` is always green.** Every milestone lands as a PR with CI passing on Linux and Windows.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ state of the research machine is owned by exactly one room. It is drawn as a cut
7575
a chamber you look into from the side, with a ceiling, a back wall and a floor its furniture and
7676
its people stand on, and between the chambers is the plant that runs the place — a service deck, a
7777
trunk down the sealed wing, and a hall of tanks and trusses underneath. None of that carries a
78-
number, because none of it records anything. The recorded passes move between the rooms. At rest there is no writing on it at all: hover a room to find out which it is,
78+
number, because none of it records anything. The recorded passes move between the rooms, and the
79+
actors walk between the stations of their own rooms and work at them — in the rooms the arm on
80+
display engages. In the rooms it does not, they stand at their posts. At rest there is no writing on it at all: hover a room to find out which it is,
7981
click it to open what that department actually did — every figure naming the artifact it was read
8082
out of — and press `labels` to put the callouts, the captions and the roster back on. Drag to pan,
8183
scroll to zoom.

src/nullius/station/render.py

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ class Principle:
519519
#: length of it takes. Agents patrol their own room and never leave it: the
520520
#: token is what moves through the station, and a role walking the corridor
521521
#: would say something about the architecture that is not true.
522-
PATROL_INSET = 34.0
522+
PATROL_INSET = 22.0
523523
PATROL_SECONDS = 19.0
524524

525525

@@ -801,10 +801,10 @@ class Kit:
801801
),
802802
"review": Kit(
803803
("vent", "sign", "conduit"),
804-
("podium", "plotwall", "shelf"),
805-
("catalogue", "printer"),
806-
("readingdesk", "readingdesk"),
807-
("stool", "plant", "poster"),
804+
("plotwall", "shelf", "pinboard"),
805+
("catalogue", "podium", "printer"),
806+
("readingdesk", "desk", "readingdesk"),
807+
("stool", "plant", "poster", "crate"),
808808
),
809809
"record": Kit(
810810
("conduit", "sign", "conduit"),
@@ -837,9 +837,9 @@ class Kit:
837837
"oracle": Kit(
838838
("vent", "sign", "vent"),
839839
("dish", "orb", "dish"),
840-
("scope", "keysafe"),
841-
("plinth", "plinth"),
842-
("hatch", "barrel", "cables"),
840+
("scope", "keysafe", "rack"),
841+
("plinth", "terminal", "plinth"),
842+
("hatch", "barrel", "cables", "crate"),
843843
),
844844
}
845845

@@ -1216,6 +1216,78 @@ def _bounds() -> dict[str, float]:
12161216
return {"x": round(x0, 2), "y": round(y0, 2), "w": round(x1 - x0, 2), "h": round(y1 - y0, 2)}
12171217

12181218

1219+
WORKABLE: frozenset[str] = frozenset(
1220+
{
1221+
"console",
1222+
"desk",
1223+
"readingdesk",
1224+
"workbench",
1225+
"bench",
1226+
"table",
1227+
"counter",
1228+
"drawingtable",
1229+
"printer",
1230+
"press",
1231+
"scanner",
1232+
"sealpress",
1233+
"podium",
1234+
"terminal",
1235+
"scope",
1236+
"binbank",
1237+
"coolant",
1238+
"partsbin",
1239+
"filewall",
1240+
"catalogue",
1241+
"rack",
1242+
"cabinet",
1243+
"locker",
1244+
"spool",
1245+
"shelf",
1246+
"stacks",
1247+
"reactor",
1248+
"vaultdoor",
1249+
"keysafe",
1250+
"maskcase",
1251+
"plinth",
1252+
"orb",
1253+
"dish",
1254+
"dummy",
1255+
"scales",
1256+
"bullion",
1257+
}
1258+
)
1259+
"""Things somebody could plausibly be doing something at.
1260+
1261+
An actor that paces back and forth between two arbitrary points is a gif. An
1262+
actor that walks to a bench, works at it, walks to a console and works at that
1263+
is the same amount of animation saying something: that the room is laid out and
1264+
that the person in it has somewhere to be. The stations are the room's own
1265+
fixtures, so nobody works at a barrel or in mid-air.
1266+
"""
1267+
1268+
1269+
def _stations(room: Room, left: float, right: float) -> list[float]:
1270+
"""Where in its own stretch of floor an actor has something to work at."""
1271+
middles = sorted(
1272+
fixture["x"] + fixture["w"] / 2
1273+
for fixture in _fixtures(room)
1274+
if str(fixture["kind"]) in WORKABLE and left <= fixture["x"] + fixture["w"] / 2 <= right
1275+
)
1276+
span = right - left
1277+
# Two stations a hand's width apart is a figure shuffling on the spot, which
1278+
# reads as worse than pacing: it looks like the animation is broken rather
1279+
# than like somebody is busy. Below that spread, walk the slice instead.
1280+
if len(middles) >= 2 and middles[-1] - middles[0] > max(46.0, span * 0.3):
1281+
first, last = middles[0], middles[-1]
1282+
between = [
1283+
middle for middle in middles[1:-1] if middle - first > 40.0 and last - middle > 40.0
1284+
]
1285+
if between:
1286+
return [first, between[len(between) // 2], last]
1287+
return [first, last]
1288+
return [left + span * 0.16, right - span * 0.16]
1289+
1290+
12191291
def _sprites(room: Room) -> list[dict[str, Any]]:
12201292
"""One figure per role that works in this room, at a workstation.
12211293
@@ -1241,25 +1313,32 @@ def _sprites(room: Room) -> list[dict[str, Any]]:
12411313
# workstations, which is the only part of the room nothing stands on.
12421314
left, right = floor.x + PATROL_INSET + index * share, 0.0
12431315
right = left + share - (12.0 if len(room.roles) > 1 else 0.0)
1244-
# Everybody is on the same floor now, because there is only one.
1245-
lane = feet
1246-
drift = 1.5 + nxt(0.0, 1.5)
1247-
turn = left + (right - left) * (0.3 + nxt(0.0, 0.4))
1316+
stations = _stations(room, left, right)
1317+
# The first station is where the figure stands when nothing is running,
1318+
# so the offsets are measured from it: an actor in a room this arm does
1319+
# not engage is at a post rather than stopped halfway across the floor.
1320+
base = stations[0]
1321+
offsets = [station - base for station in stations]
1322+
if len(offsets) < 3:
1323+
offsets.append(offsets[0])
12481324
out.append(
12491325
{
12501326
"role": role.value,
1251-
"x": round((left + right) / 2, 2),
1252-
"y": round(lane, 2),
1327+
"label": role.value.upper(),
1328+
"tag_w": round(_measure(role.value.upper(), 11.0) + 18.0, 2),
1329+
"turns": len(stations),
1330+
"x": round(base, 2),
1331+
"y": round(feet, 2),
12531332
"h": AGENT_H,
1254-
"d": (
1255-
f"M{left:.1f} {lane:.1f} L{turn:.1f} {lane - drift:.1f} "
1256-
f"L{right:.1f} {lane:.1f} L{turn:.1f} {lane - drift:.1f} "
1257-
f"L{left:.1f} {lane:.1f}"
1258-
),
1333+
"stations": [round(station, 2) for station in stations],
1334+
"s0": round(offsets[0], 2),
1335+
"s1": round(offsets[1], 2),
1336+
"s2": round(offsets[2], 2),
12591337
"dur": round(PATROL_SECONDS + nxt(0.0, 9.0), 2),
12601338
"begin": round(-nxt(0.0, 18.0), 2),
12611339
"delay": round(nxt(0.0, 2.6), 2),
12621340
"period": round(1.1 + nxt(0.0, 0.5), 2),
1341+
"work": round(1.5 + nxt(0.0, 0.8), 2),
12631342
}
12641343
)
12651344
return out

src/nullius/station/templates/agents.html

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -645,13 +645,36 @@
645645

646646
{% macro agent(s, hue, dim) %}
647647
{% set h = s.h %}
648-
<g class="agent{{ ' dimmed' if dim else '' }}"
649-
style="--step:{{ s.period }}s;--step-delay:-{{ s.delay }}s">
650-
<animateMotion dur="{{ s.dur }}s" begin="{{ s.begin }}s" repeatCount="indefinite"
651-
calcMode="linear" path="{{ s.d }}"/>
652-
<ellipse cx="0" cy="1.5" rx="{{ h * 0.27 }}" ry="{{ h * 0.08 }}" fill="#000" opacity=".5"/>
653-
<ellipse cx="0" cy="1.5" rx="{{ h * 0.17 }}" ry="{{ h * 0.05 }}" fill="#000" opacity=".35"/>
654-
<g class="walk" filter="url(#ink)">{{ build(s.role, h, hue) }}</g>
648+
{# The figure is placed at its first station and moves from there, so an actor
649+
in a room the arm on display does not engage stands at a post rather than
650+
halfway across the floor with the animation switched off. #}
651+
<g class="agent{{ ' two' if s.turns < 3 else '' }}{{ ' dimmed' if dim else '' }}"
652+
transform="translate({{ s.x }} {{ s.y }})"
653+
style="--s0:{{ s.s0 }}px;--s1:{{ s.s1 }}px;--s2:{{ s.s2 }}px;--dur:{{ s.dur }}s;
654+
--phase:{{ s.begin }}s;--step:{{ s.period }}s;--step-delay:-{{ s.delay }}s;
655+
--work:{{ s.work }}s">
656+
<g class="pace">
657+
<ellipse cx="0" cy="1.5" rx="{{ h * 0.27 }}" ry="{{ h * 0.08 }}" fill="#000" opacity=".5"/>
658+
<ellipse cx="0" cy="1.5" rx="{{ h * 0.17 }}" ry="{{ h * 0.05 }}" fill="#000" opacity=".35"/>
659+
<g class="walk">
660+
<g class="turn">
661+
<g class="pose" filter="url(#ink)">
662+
{# A box the figure sits in the middle of. The mirror is taken about
663+
the centre of what is drawn, and what is drawn is not symmetric --
664+
a wrench sticks out one side and a T-square the other -- so
665+
without this the actor jumps sideways every time it turns round. #}
666+
<rect x="{{ -h * 0.56 }}" y="{{ -h }}" width="{{ h * 1.12 }}" height="{{ h }}"
667+
fill="none" stroke="none"/>
668+
{{ build(s.role, h, hue) }}
669+
</g>
670+
</g>
671+
</g>
672+
<g class="nameplate" transform="translate(0 {{ -h - 15 }})">
673+
<rect x="{{ -s.tag_w / 2 }}" y="-11" width="{{ s.tag_w }}" height="16" rx="8"
674+
stroke="{{ hue }}" stroke-width="1.2" stroke-opacity=".45"/>
675+
<text x="0" y="1" text-anchor="middle">{{ s.label }}</text>
676+
</g>
677+
</g>
655678
<title>{{ s.role }}</title>
656679
</g>
657680
{% endmacro %}

src/nullius/station/templates/parts.html

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,29 @@
14151415
stroke="{{ ['var(--m-steel)', 'var(--m-brass)', 'var(--m-paint)'][i] }}"
14161416
stroke-width="6" opacity=".7"/>
14171417
{% endfor %}
1418+
{# A row of plant standing on the deck, edge to edge. Three tanks and two
1419+
exchangers left the right-hand third of the hall thinner than the left,
1420+
which reads as the building running out rather than as a building. #}
1421+
{% for i in range(9) %}
1422+
{% set gw = w.w * [0.07, 0.05, 0.09, 0.06, 0.05, 0.08, 0.06, 0.07, 0.05][i] %}
1423+
{% set gh = w.h * [0.17, 0.11, 0.22, 0.14, 0.09, 0.19, 0.12, 0.16, 0.1][i] %}
1424+
{% set gx = w.x + 22 + w.w * [0.0, 0.09, 0.17, 0.29, 0.37, 0.45, 0.57, 0.68, 0.79][i] %}
1425+
{% set gy = w.y + w.h - 20 - gh %}
1426+
<ellipse cx="{{ gx + gw / 2 }}" cy="{{ gy + gh + 2 }}" rx="{{ gw * 0.56 }}" ry="6" fill="#000"
1427+
opacity=".4"/>
1428+
<rect x="{{ gx }}" y="{{ gy }}" width="{{ gw }}" height="{{ gh }}" rx="3"
1429+
fill="{{ ['var(--m-steel)', 'var(--wallcap)', 'var(--m-steel-2)'][i % 3] }}"
1430+
stroke="#050912" stroke-width="1.6" stroke-opacity=".6"/>
1431+
<rect x="{{ gx }}" y="{{ gy }}" width="{{ gw }}" height="{{ gh * 0.16 }}" rx="3" fill="#fff"
1432+
opacity=".09"/>
1433+
{% for k in range(3) %}
1434+
<rect x="{{ gx + gw * 0.14 }}" y="{{ gy + gh * (0.28 + 0.2 * k) }}" width="{{ gw * 0.72 }}"
1435+
height="{{ gh * 0.11 }}" rx="1.6" fill="#050912" opacity=".4"/>
1436+
{% endfor %}
1437+
<circle cx="{{ gx + gw * 0.82 }}" cy="{{ gy + gh * 0.16 }}" r="{{ gw * 0.055 }}"
1438+
fill="{{ ['var(--m-hazard)', hue, 'var(--m-paint)'][i % 3] }}" opacity=".6"
1439+
class="blink" style="animation-delay:{{ i * 0.4 }}s"/>
1440+
{% endfor %}
14181441
<rect x="{{ w.x }}" y="{{ w.y + w.h - 20 }}" width="{{ w.w }}" height="20" fill="var(--slab)"/>
14191442
<rect x="{{ w.x }}" y="{{ w.y + w.h - 20 }}" width="{{ w.w }}" height="20"
14201443
fill="url(#plating)" opacity=".5"/>

0 commit comments

Comments
 (0)