Skip to content

Observer trajectory lives on an ENVIRONMENT SLOT, so containers cannot carry it: dict fields, list elements and function parameters have no history #1048

Description

@InauguralPhysicist

Found by phugoid rung 4 (the N-aircraft swarm), 2026-08-26, eigenscript v0.41.0.

This body has been rewritten twice. The original claimed a runtime-sized population "cannot be observed per-aircraft at all"; the first correction weakened that to "only a lexical name, so it forces eval-generated source". Both were false — each was a negative claim published after a non-exhaustive search. eval works, and so do closures. The accurate statement is below, and it is a claim about the mechanism rather than an enumeration of what I happened to try. See the correction comments for the history.

The mechanism

Observer trajectory is keyed to an environment slotenv_obs_slot(Env *e, int idx) returns e->obs[idx] (src/eigenscript.h:1352), and the header says so directly at line 346: "The Value carries no observer state."

So a per-entity observer channel requires a persistent environment slot per entity. That is the whole rule, and it predicts every case:

form persistent env slot? trajectory
distinct named locals yes works
closure captured local (one factory call per entity) yes works, N chosen at runtime
eval-generated names yes works, N chosen at runtime
dict field ch.a no — a Value in a container equilibrium, no history
list element xs[0] no — a Value in a container equilibrium, no history
function parameter slot exists but the frame dies each call equilibrium, no history

Closures, verified with N from argv:

define make_ch as:
    local q is 0.0
    define step(v) as:
        q is v
        return report of q
    return step
$ eigenscript g8.eigs 6
closure channels (n=6 from argv): ["moving","oscillating","moving","oscillating","moving","oscillating"]

Alternating decaying/oscillating trajectories, correct per-channel verdicts, ordinary static source that lints normally.

What the actual gap is

Containers cannot carry observer state, so per-entity observation cannot be expressed the way a consumer would naturally reach for it — a fleet is a list, a registry is a dict, and neither can be observed element-wise. The available forms all require one binding per entity: a named local, or a closures captured local.

That is a real ergonomic gap and it has a sharp failure mode: the obvious loop

loop while i < n:
    local q is fleet[i][2]        # ONE binding, rebound N times
    if diverging of q: ...

does not merely lose resolution, it manufactures verdicts — the window becomes the round-robin interleave, so a monotonically decaying trajectory reads oscillating:

shared binding : a-verdict=oscillating   b-verdict=oscillating
named bindings : a-verdict=moving        b-verdict=oscillating

That is silent-wrong with no diagnostic, and it is what rung 4s own first draft shipped in every arm.

The ask, unchanged and now correctly weighted

Make dict-field and list-element assignment carry trajectory keyed by (container identity, key). With closures available this is convenience rather than necessity — but it is the form consumers reach for first, and the alternative failure is silent rather than loud.

A secondary and cheaper mitigation: make the interleaved-binding case detectable. A binding whose observed values jump between unrelated trajectories is a bug in every case I can construct, and the observer has the history to notice.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions