Skip to content

The 4D Generator β€” Journey, Failure Catalogue, and What It Teaches About AI Coding Agents

Companion reads: Vibe Programming for the general AI-assisted-development thesis this document is a deep case study of Β· the Time Machine feature guide (BIMUserGuide.md) for what this generator produces Β· bim-ootb/prompts/4D_MODEL_INTEGRITY.md and 4D_GANTT_TM_REFACTOR.md for the raw, dated working notes this document distills.

This is a technical reference for two audiences at once: developers who need to understand how this project's 4D construction-schedule generator actually works and where its known sharp edges are, and AI researchers interested in a concrete, evidenced account of where large-language-model coding agents fail on a genuinely novel domain β€” and what process change fixed it. Both audiences need the same thing: what actually happened, with citations, not a polished summary.

4D scheduling is a use case here, not the subject. The subject is what happens when a vibe-programming session meets a domain it has no prior familiarity with β€” it drifts, and getting it back on track takes a real sequence of deliberate steps, not one clever fix. 4D/Time-Machine simply produced the deepest, most instrumented example of that pattern on the day this document was written. It is not the only one: the same shape β€” work done in isolation, never reconciled back, silently drifting until something finally checks and catches it β€” surfaced again the same day in a completely unrelated domain, publishing this project's own documentation (Β§7). Read the failure catalogue below as an instance of the general problem Vibe Programming names, not the problem.

Executive summary β€” is this codebase well designed?

Asked plainly, mid-session, after the failure catalogue below was already found. The honest answer, scored against that evidence rather than impression:

  • Conceptual architecture β€” solid. Layered separation of concerns (Β§B in 4D_MODEL_INTEGRITY.md), level-major Location-Based Scheduling (Β§2.2), a template/instance split, an extract-don't-invent discipline. The right ideas, and they're written down.
  • Duplication discipline β€” the real weak point. The same physical relation independently reimplemented more than once, silently disagreeing, is the pattern behind Β§3.2 and Β§3.3 both. A fix to one copy does not reach the others. This is the single highest-leverage thing to fix architecturally, ahead of anything else in this document.
  • Test/CI wiring β€” mostly decorative today. Extensive witness coverage exists; Β§3.7 found none of it wired into the pipeline that would need to run it automatically. A safety net not connected to a trigger is not a safety net.
  • Documentation health β€” was actively harmful, now improving. Multi-thousand-line files grown by append-only session logging is what produced Β§3.1's three-times-restated retraction. One file was already internally flagged (time_machine.js, 8,834 lines, its own tracked refactor-seam doc) as a known instance of this β€” known, not yet acted on.
  • Cross-repo and deploy sync β€” real, found twice in one day. Β§3.6's precondition (Β§3.4) and Β§7's orphaned-branch page are the same underlying gap: work landing in one place without a mechanical check that it also reached everywhere it needed to.

Bottom line: well-designed in principle, drifted from that design in practice. Everything found today was fixable by consolidation β€” delete a duplicate, wire a check in, split a file, merge a branch back β€” not by redesign. "Easy to maintain" as it stands would overstate it; "maintainable if today's discipline holds" is accurate.


1. Why this domain is hard for an AI coding agent specifically

Vibe Programming already states the general thesis: "known framework = reliable code, novel spatial reasoning = drift." Generating a construction schedule from a 3D BIM model is squarely in the drift half. There is no large corpus of "IFC element β†’ construction task sequence" examples in any LLM's training data, for a structural reason: the mapping isn't textual, it's physical. An AI can write a correct SQL query for "elements grouped by storey" fluently, because SQL grouping is a well-worn pattern. It has no comparably well-worn pattern for "can this slab exist yet, given which columns are actually built" β€” that requires reasoning about gravity, support, and sequencing simultaneously, which is not a pattern that shows up in code search results.

The consequence, measured on this exact codebase before the process changed (2026-08-26, one session): five retractions, a structural-class regex that would have made 438 curtain-wall glazing panels load-bearing, a quantifier bug that produced 1,961 instead of 95, a missing upper bound that let a single riser "bore" the whole building. None of these are exotic bugs. They are what happens when an agent is asked to invent spatial-physical reasoning it has no textual precedent for, and does its best β€” confidently, plausibly, and wrong.

The fix that worked, documented in full below, was not a better prompt. It was accepting that the agent cannot originate this reasoning reliably and building a process that doesn't require it to: extract the relation from data instead of deriving it from first principles, and verify every claim numerically instead of trusting the reasoning that produced it.


2. The domain, correctly framed

2.1 The pipeline shape

4D_template.json (canonical, building-agnostic programme)
        ↓
instantiateTemplate()  β€” per-building task grid
        ↓
Time Machine β€” the editable, persisted schedule a user actually sees and drags

The template declares what happens (phases, trade sequencing, lag rules) once, for all buildings. Per-building instantiation decides where β€” which elements land in which phase, on which level, in what order within that level. Getting the "where" wrong is where nearly every defect below lives; the template layer itself has been comparatively stable.

2.2 Location-Based Scheduling is the correct model, not an invented one

This project's own model spec (4D_MODEL_INTEGRITY.md Β§A) organizes the schedule level-major, phases inside: Building β†’ Level β†’ Phase β†’ Layer β†’ Element. Before this was understood to be industry-standard, it was discovered the hard way: phase-major ordering (all Superstructure before all Architecture) produced 17 bearing violations on a 20-element sandbox, because it put a Level-2 slab before the Level-1 walls it rests on. Inverting to level-major cut that to 10.

This is not a house style. It is Location-Based Scheduling (LBS) β€” also called Line-of-Balance or flowline scheduling β€” the established alternative to plain trade-first CPM scheduling, used specifically for repetitive multi-story vertical construction (hospitals, offices, residential towers), because it is the representation that makes physical-support ordering visible instead of accidental. A generator for this domain should default to level-major grouping; that default is not a stylistic choice, it's the reason the model works at all.

2.3 Resourcing without an engineer: crew rates as the build-up throttle, and a clean exit to a real schedule

Every 4D-BIM tool in mainstream use β€” Synchro, Vico β€” starts from a schedule someone already built: authored by hand or imported from Primavera P6 or MS Project, then linked to the model. That's correct for the tool's job, but it means there is no such thing as opening an arbitrary building and getting a resourced, sequenced schedule with zero project-specific planning. The closest AI-driven comparable, ALICE Technologies, is explicit that it optimizes from an existing baseline schedule and activity/logic set β€” it accelerates re-planning, it doesn't remove the planning step. Sources: ALICE Technologies, Bentley SYNCHRO.

This generator does remove it, for the specific purpose of instant, on-the-fly demonstration β€” walking into a room with a building's IFC and no scheduler present, and having a legally-sequenced, resourced, dated schedule the moment the model loads. That's only honest as a claim if the mechanism producing "resourced" is named precisely, so here it is:

What drives build-up speed. Every trade has a day-rate and a crew count in a small, declarative table (rates/sequence_rules.json's LABOR_RATES), not a simulation: max_crews sets capacity (Β§CREW_DEMAND reports capacity = crews Γ— projectDays, and flags util>100% when a trade's declared work can't fit its declared crews β€” the log literally tells you which trade needs more crews, e.g. measured on Hospital: PLUMBER demand=1563.8cd crews=2 capacity=2970cd util=52.7%). Real per-element geometry then modulates that flat rate rather than treating every element identically β€” Β§HEAVY_MEMBER_SPEED_LIMIT redistributes each trade's flat total by each element's actual length, so a 7-meter beam and a 1-meter one don't install at the same assumed speed. The result, Β§HR_COST, is a real person-day and dollar figure per trade (STEEL_ERECTOR personDays=815.0 @195/d = 158925), derived, not typed in.

The adjustment path is fast in two different ways, and they are not the same mechanism. Every dated bar in the live Gantt is directly draggable, lockable, and undoable β€” an instant, in-session adjustment with the persistence and integrity guarantees covered in Β§3.5–3.6. The resourcing assumption itself (how many crews a trade gets, what a crew costs) is a one-line edit to rates/sequence_rules.json's max_crews_fixed followed by a regenerate β€” seconds, not a re-plan, but a distinct step from dragging a bar. Presenting both accurately matters more than presenting one as more magical than it is.

The exit ramp is real, not aspirational. The Time Machine's toolbar carries a ⇄ P6/MSP control, and _tmPersistEdit's own call sites (verified in Β§3.5's investigation) include import_p6 as a first-class edit path alongside drag/link/undo β€” importing a real Primavera P6 or MS Project schedule is not a separate product mode bolted on, it writes into the same persisted schedule tables the auto-generated one uses. The generated schedule is a placeholder with real physics, not a toy: it is legally sequenced (support-ordered, crew-capacity-checked) from the moment it appears, and it is designed to be replaced, not defended, the moment an actual project scheduler's P6 file exists.

Sources checked 2026-08-27: ALICE Technologies Β· Bentley SYNCHRO. Web-search-level evidence, not an exhaustive market survey β€” treat the comparison as directionally accurate, not a verified "no prior art" claim.


3. Failure catalogue

Each entry: what looked fine, what was actually wrong, how it was proven, and the fix. All findings below are dated 2026-08-27 unless stated otherwise, verified against merged code, not self-reports.

3.1 The spec described dead code as canonical

Symptom. Every fix aimed at "the 4D model" for months, including the day before this one, patched bar_model.js's tree model β€” the model 4D_MODEL_INTEGRITY.md Β§A described as canonical, with the invariant "no edges emitted, sibling order IS the order."

Root cause. That tree model is not what runs. The live path is schedule_author.js:425 instantiateTemplate(), an explicit edge-based task graph β€” the opposite of "no edges emitted." The project's own ownership table (Β§I, built the same day) had already correctly named instantiateTemplate as the owner of "what is the task grid?" β€” it simply was never reconciled with Β§A above it. Two sections of the same file contradicted each other from the day one was written, and a later session re-derived the contradiction as if it were new, burning three fix cycles wiring edges into a model whose own text said it had none.

Fix. A decision, not a patch: the user ruled the template path canonical. 4D_MODEL_INTEGRITY.md Β§A now carries a superseded banner. A PreToolUse hook (bim-compiler/.claude/hooks/block_bar_model.sh) mechanically blocks any Edit/Write to a path ending bar_model.js, repo-wide, in any worktree β€” proven live with a real blocked edit call, not just documented. Lesson: when two authoritative documents disagree, the fix is reconciliation plus an enforced, un-skippable guard β€” not another spec section explaining the disagreement.

3.2 Storey/level assignment fabricated data past its own safety guard

Symptom. elements_meta.storey is NULL for 15–86% of scheduled elements fleet-wide (Terminal 69.9%, Duplex 86.0%). The schedule invents storeys to compensate: Terminal declares 6, the schedule produces 22 bands, one of which β€” "06 ROOF LEVEL," declared by 10 elements β€” collects 10,950.

Root cause, found by tracing the actual call path rather than trusting the existing guard. deriveBandRanks (schedule_gate.js:350) deliberately excludes _UNKNOWN from the ladder β€” a correct-looking safety guard, added after a measured Hospital regression. But upstream, assignStoreyByZ (schedule_author.js:342) silently overwrites every _UNKNOWN element with the nearest real storey name by median center-Z before that guard ever runs. The guard was dead code from the moment it was written, because the thing it was meant to catch never survives long enough to be checked.

A second, independent implementation of the same relation was already live and correct. LevelDeriver (viewer/lib/level_deriver.js) β€” a tiered relation (containment β†’ declared name/spatial_structure β†’ geometry grid β†’ none-counted) β€” was already running in production, but only for the display timeline (via location_axis.js), never for the task grid that actually generates the schedule. Two computations of "what level is this on," on the same elements, in the same call, disagreeing.

Fix, verified not assumed. Confirmed LevelDeriver reads the database directly and never touches the corrupted field, so swapping to it removes the exposure rather than relocating it (bim-ootb PR #1556). Shipped behind a flag, default off, proven byte-identical to prior behavior with the flag off across three existing witnesses. Left off deliberately β€” see Β§3.4. Lesson: a guard that excludes a bad value only works if nothing upstream launders that value into a good-looking one first. Verify the guard actually sees what it's supposed to catch, don't just read its logic in isolation.

A related, measured non-obvious result: the rate at which storeys are fabricated (87%/31%/15% across three buildings) does not predict the rate at which the schedule's actual structure changes (9.6%/7.8%/40% for the same three) β€” a wrong-but-plausible guess and a genuinely different grid produce very different downstream impact from similar-looking input corruption. Don't use one as a proxy for the other.

3.3 A "does S support T" relation, independently reimplemented three times

4D_MODEL_INTEGRITY.md Β§I.1: support_sweep.js:410 (the declared owner), cpm_schedule.js:81 (a full independent reimplementation, not a delegation), and schedule_gate.js:1195 (a third copy with a different, stricter upper bound added later to fix a specific false-positive class). Copies 1 and 2 never received that later fix. Measured consequence: 32.0% of Hospital's in-scope bearing contacts are "supports" whose top sits above the base they're claimed to carry β€” and the solve runs on copy 2, the one that never got fixed. Lesson: a physical relation computed independently in more than one place will drift the moment either copy is patched alone β€” this is not a hypothetical risk, it happened, silently, for an unknown period before being found.

3.4 A fix correctly not shipped, because its precondition was checked first

LevelDeriver's higher tiers need spatial_structure with real elevation data. Checked before flipping the flag: Duplex and Hospital have no spatial_structure table at all. Flipping the flag today would have degraded the grid to a uniform 3-meter fallback, inflating Hospital from 8 real storeys to 16 fabricated ones, with grid lines occasionally winning a label on absurd margins (one "Level 6" label won on 2 votes out of 5,676). The flag was left off, and the real blocker β€” the extractor fix committed that same morning (d0b226dce, writing IfcBuildingStorey.Elevation) never having reached the already-shipped databases β€” was named as the actual next step. Lesson: proving a fix is safe-by-construction (flag off, byte-identical) is necessary but not sufficient; also prove the condition required to turn it on, before anyone is tempted to turn it on.

3.5 A witness that was correct when written, and silently went stale

witness_gantt_edit_persist (W-PERS) exists to prove a Gantt edit persists correctly. Its split-mode fixture never set _dbPersistUrl β€” because when the witness was written, the real code didn't read that field either. A later fix (Β§S78) changed the real persist logic to prefer _dbPersistUrl for split-mode buildings; nobody updated the witness fixture to match. The witness kept passing throughout β€” it was proving a version of reality that no longer existed.

The proof that this was real, not theoretical: the actual pre-fix bug was reverted and both witnesses run against it. The old witness stayed green β€” pass=14 fail=0, "PASS β€” every Gantt edit path persists" β€” on the exact defect it existed to catch. The extended witness correctly failed, pass=17 fail=1. That side-by-side result is the only real evidence a witness extension closed a gap; a witness that merely "looks right" against the current code proves nothing about whether it would have caught the bug it's named for. Lesson: to validate a regression guard, don't just make it pass on the fixed code β€” make it fail on the broken code first, on purpose.

3.6 A cache evictor, not the save path, silently destroyed saved user data

Symptom. Editing a schedule on a large building (Hospital, ~56MB) appeared to save successfully (ok=true, correct cache key logged, no error) β€” but on reload, the edit was gone and the app silently re-fetched the pristine network copy.

Root cause, found only by instrumenting the actual mutation, not the reported call site. The persist function itself was innocent. A separate, oversized write (a ~240MB geometry file) hit Chrome's hard per-value IndexedDB limit (~127MiB) and aborted β€” a failure that can never succeed at any quota. The abort handler discarded the real error (tx.error) and assumed a quota problem, triggering an "evict the 4 oldest entries" fallback. The cache held exactly 2 entries, so it deleted everything β€” including the slot holding the user's edited schedule. A second, independent bug amplified it: saving an edit never updated that entry's last-used timestamp, so the just-edited slot looked oldest and was evicted first. Editing your schedule moved your unsaved work to the front of the deletion queue.

Fix (bim-ootb PR #1555): read the real abort error before deciding to evict; evict only on a genuine quota error; stamp the timestamp on every persist; surface a previously-nonexistent user-visible failure warning. Proven with real read-back across three sequential persists, not a single write. A residual gap found by an independent audit, not the original fix's self-report: two narrow code paths still yield a falsy error name and fall through to eviction regardless β€” the fix closed the reported case, not every case with the same shape. Lesson: the module that logs success is not necessarily the module that caused the failure. When a symptom and its logged cause don't line up, instrument the actual mutation, and always look one layer past the fix that "worked" for a second path with the same failure shape.

3.7 Green CI proved nothing about any of the above

An audit of the four PRs above found: zero of the new witnesses/probes added that day were wired into CI. Every PR showed green because of an unrelated, pre-existing smoke suite; the new checks were, at best, syntax-parsed. The single worst case: the file carrying the storey-datum fix (Β§3.2's sibling bug, room_walker.js) sits in a directory both CI jobs explicitly exclude β€” it has no automated check of any kind, not even a parse. The project's own PR template asks for manual browser testing and screenshots, directly contradicting this project's own no-visual-verification rule. Lesson: "CI is green" is a claim about which jobs ran, not about what they checked. Verify the specific new assertion is actually invoked by the pipeline before treating a merged PR as regression-proof.


4. The AI-research finding: what actually stopped the thrashing

The single sentence version, stated by the domain owner mid-investigation and confirmed by every failure above: the model optimises the artefact in front of it and does not check whether the construct can represent the answer β€” so every fix moves the defect sideways and the loop never closes. A single long-running session, however capable, re-derives and retracts the same finding when nothing external forces it to check first β€” measured directly: the same fact (Β§3.1) was independently restated three separate times in one file in one day, and a written instruction to "re-read the spec before each thinking pass" failed within hours of being added, in the same document.

What worked instead, same day, is a specific, reproducible pattern:

  1. Orchestrator-worker, not one long session. One session holds the plan and never touches raw exploration; each actual code change goes to a fresh, single-purpose agent dispatch with an explicit stop-and-report condition ("if this needs a new architectural decision, stop and tell me β€” don't invent one").
  2. Numeric proof is the only accepted evidence. Every dispatch was required to produce a before/after diff of real, persisted state β€” a byte count, a timestamp, a read-back value β€” never a description of what should have happened. This is what caught Β§3.6: the fix that "returned ok=true" was not accepted as proof until the actual store contents were read back.
  3. Guardrails are enforced mechanically, never left to memory. Β§3.1's hook is the clearest instance: a written rule ("don't edit this file") had already failed once as prose; the fix was a PreToolUse hook that makes the mistake structurally impossible, verified by actually triggering it.
  4. Self-reports are audited against merged code, not trusted. Every "done" claim in this document was independently re-verified by a separate pass reading the actual diff β€” which is precisely how Β§3.6's residual gap and Β§3.7's CI illusion were found. Both were real and both were missed by the fix's own author in the same turn it shipped.
  5. Wrong hypotheses get refuted before they get built, not after. A proposed reuse of an existing UI module's storey logic (Β§4's own initial idea) was checked against the actual code before anything was written, and found to be the same bug wearing a different name β€” refuting it cost one dispatch; building it would have cost a rewrite.

None of this required a different or better model. The roster was unchanged from the prior working session (Vibe Programming Β§Capability Snapshot). What changed was the shape of the work.


5. Journey, condensed

When What happened
2026-08-26 A full session, four review rounds, five retractions, patching a scheduler whose design could not express the right answer. User ruling: "WITNESS is moot if underlying design is poor."
2026-08-27 early A new session finds Β§3.1 (spec contradicts itself) but retraces the same finding three times in one file before closing β€” the "re-read the spec each pass" rule fails within hours of being written.
2026-08-27 Model decision made (template path canonical); Β§3.1's hook built and proven live; six bounded dispatches run end-to-end: Β§3.2's datum fix, Β§3.5's witness gap closed and proven by reverting the real bug, Β§3.6's data-loss bug root-caused and fixed, Β§3.2's LevelDeriver swap shipped flag-off with its precondition checked and found missing, Β§3.7's CI audit, this document.
Ongoing Β§3.4's flag stays off pending the DB backfill; Β§3.3's three-copy support relation and the legacy ZoneIndex/GANTT_STOREY_Z fabrication path (a related but distinct third consumer β€” see 4D_MODEL_INTEGRITY.md Β§I.3a) remain named, not chased.

6. Open items β€” read before extending this generator

  • LevelDeriver flag (opts.levelSource='deriver', schedule_author.js) stays OFF until spatial_structure with real elevation exists on every shipped building DB. Flipping it before then actively makes level assignment worse, not better β€” verified, not assumed.
  • Two more independent copies of assignStoreyByZ's fabrication logic exist, feeding the movie/x-ray builders (time_machine.js:3687, :4563) via a shared zone_index.js β€” a second, separate mechanism from Β§3.2's task-grid copy. Not touched by the fixes above.
  • No CI job runs any of this project's 4D witnesses. Still not implemented: one new CI job executing the node-runnable checks that already exist, and spatial_structure added to the DB-integrity required-table list. (Done, 2026-08-27: the buried Β§I.3a/Β§I.3 warnings in 4D_MODEL_INTEGRITY.md are now surfaced at the top of its Β§L session-handoff block, not left mid-file.)
  • The support-relation triplication (Β§3.3) has one declared owner and two independent copies still live; only one of the three received the later false-positive fix.
  • Four items queued live, not started: residual midair elements, build-up elements crowding the start of their bar instead of spreading across it, whether that crowding masks a real resource-timing problem, and a full-cycle review of the standalone Schedule Editor. Recorded in 4D_GANTT_TM_REFACTOR.md Β§FUTURE (2026-08-27).

7. The pattern isn't 4D-specific β€” the same day, a second domain

Everything in Β§3 is about spatial/physical reasoning going wrong. The same shape of failure β€” and the same shape of recovery β€” showed up the same day in a domain with nothing to do with geometry: publishing this project's own documentation.

What happened. A dispatched agent, mid-way through an unrelated docs task, ran the sanctioned deploy script's dry-run check before publishing β€” and it aborted. A live, publicly published page (this project's own academic paper) would have been deleted by the deploy, because its markdown source existed only on a branch that had been deployed to the live site once, directly, and never merged back into any branch anyone was actually working from. The branch had drifted 1,337 commits from where active work lived. Nobody had done anything wrong in the moment it happened β€” the drift accumulated silently across however many sessions touched docs since, each one working from a tree that looked complete because nothing told it otherwise.

Why this is the same lesson as Β§4, not a new one. Isolated work that never gets reconciled back into the shared source of truth is invisible until something checks β€” the deploy guard here plays exactly the role Β§4's PreToolUse hook and Β§3.5's revert-and-reverify test play elsewhere: a mechanical check that doesn't rely on anyone remembering. And the recovery followed the same discipline as every Β§3 fix: the agent did not override the abort, it did not guess a fix, it named the root cause precisely and surfaced three options β€” including the one that looked fastest (let the guard-blessed shrink through) and was actually the wrong one β€” to a human, who picked the minimal, purely-additive fix (restore the missing source from the orphaned branch) rather than the destructive one.

The generalizable point: neither the domain expert nor the AI needs to have seen a given domain before for this pattern to apply. Construction-schedule support ordering and documentation branch hygiene are unrelated fields. What's shared is the failure mode β€” undetected drift between an isolated working copy and the shared source of truth β€” and the fix β€” a mechanical check that fires whether or not anyone remembers to look, plus a human decision at the one point where a wrong choice would have been externally visible and hard to walk back. 4D happened to be where this project spent the most hours today. It was not the only place the lesson applied.

This document should gain new dated entries the same way Vibe Programming's capability table does β€” append, never rewrite history.