Skip to content

Prompt economy: measure the prompt, and errors that name an alternative - #1321

Open
andreasronge wants to merge 2 commits into
mainfrom
plan/prompt-economy
Open

Prompt economy: measure the prompt, and errors that name an alternative#1321
andreasronge wants to merge 2 commits into
mainfrom
plan/prompt-economy

Conversation

@andreasronge

Copy link
Copy Markdown
Owner

Two slices, one commit each. Neither changes a word of the system prompt: the measuring instrument lands independently of the thing it will be used to measure.

Slice A — feat(prompt): measure the prompt

priv/preludes/kernel/agent.prompt.clj renders the system prompt every agent gets, and it is the one authored surface in the tree with no authoring budget. Runtime limits (max_transcript_chars) bound one run, not the source. PR #1310 grew the instruction text twice — including the time it was repaired by deleting a clause — because there was nothing to measure with.

New pure prelude prompt.audit, all functions taking a string:

  • segments — the ordered emission segments (marker, protocol, language, examples, api-heading, api-notes, api-legend, api-entries); joining them reproduces the input exactly.
  • measure{"rows" [...] "recognised?" bool}, one row per segment plus derived authored, dynamic, total.
  • delta — per-segment character change between two renderings.

Because the input is a string, one implementation serves the REPL reading a recorded run, the tests guarding the committed artifacts, and the gate.

Recognition fails closed. Manifest text reaches the prompt unescaped, so a docstring that reproduces an anchor, a duplicated API notes heading, or an entry list truncated by even one character all yield a single unrecognised segment rather than a guess.

The gate reads total, not authored, on a pinned canonical fixture that renders all eight segments. authored would not move when render-entry grows — exactly the change the gate exists to catch. The ceiling is stated with its reason in the test; raising it is allowed, raising it silently is not.

Two corrections to the plan's own reckoning:

  • It listed 12 files for the inspection-analysis-v2v3 bump. Re-deriving the list by repository search found 16, of which 13 needed changing — analysis_session_builder.ex was missing from the plan. The other three are docs/plans/* historical records, left at v2 rather than rewritten.
  • AnalysisResources was the silent one, as predicted: it pattern-matches the profile ID as a module-attribute literal, so a missed bump fails every v3 capture with :invalid_analysis_resources.

Slice B — fix(lisp): name an alternative in lookup errors

Given a failed name lookup, take the highest rung reachable: name one alternative verified to exist, list the bounded set the name came from, or point at search. The first two cost the model no turns and the third costs one, and no rung may print a name that was not resolved first.

The plan's premise needed correcting. It places three of the six sites in format_closure_error/1. Bare symbols never reach it — check_undefined_var_candidates/2 intercepts every statically visible undefined name before evaluation. The hint ladder was effectively dead code for the model-facing case: map_indexed produced a bare Undefined variable: map_indexed with no hint at all. Fixing only format_closure_error would have produced no observable change. The ladder now lives in one function both producers call.

Site Before After
Unresolved bare name Undefined variable: x points at (apropos "x") and states it searches prelude exports — not builtins, lexical bindings, or earlier definitions
Underscore names rewrote any foo_barfoo-bar unverified hyphenated candidate looked up in Env.initial/0 and suggested only when it resolves, then the verified fuzzy match, then the convention with no name asserted
Unsupported Java class named the class and stopped lists admitted class names from the distinct class_ids of Surface.references/0 — not class_spellings/0, which carries inventory-only classes and constructor spellings like "java.util.Date."
Java arity (analysis path) Java reference integer_parse_int expects arity [1] names a canonical admitted spelling and the accepted arities; a field called as a function is named as a field rather than reported as accepting an empty arity
Receiver mismatch unsupported Java member for receiver class names the member; the receiver profile stays structured detail, since :unsupported is not a type the model could act on
@clojure_alternatives import denied because there is "no Java interop" corrected; refer, defmacro, eval, read-string added

The format, re-find and re-seq entries are deleted: all three are builtins, so the entries were unreachable and asserted that three available functions were unavailable — and the re-find entry offered grep, which has never existed in this dialect. A test now holds both halves of the invariant: every key is genuinely unbound, and every name the hints offer resolves.

Authority refusals keep the carve-out — listing the tools a caller does hold is fine and already happens; listing what a denial hides is not. Asserted per clause, not as a blanket rule.

The rollback statement went into agent.feedback rather than any evaluator condition: only one of the three failure paths carries details.message, so a sentence written into a condition would reach the model on one path in three. Added to all three with an integration test each.

Codex review

Three rounds, the cap agreed for this work. Every round found something real.

Round Findings Outcome
1 1 × P1, 4 × P2 4 fixed, 1 declined
2 2 × P2 both fixed
3 1 × P2 fixed

Round 1's P1 was a correctness bug I had misfiled as the documented residual: a namespace docstring containing a blank line ended api-notes early and counted the remainder — manifest text — as authored. That is categorically worse than the residual, which only misattributes in the approximate direction. The notes segment now ends at the last blank line before the legend, which fixes it without the literal-matching the plan explicitly rejected.

Round 3's finding was the sharpest: line counting used a regex, and the regex runtime truncates its input at 32,768 bytes and undercounts silently rather than failing. The round-2 test sat at 32,229 bytes — just under. Line counting now uses sequence functions, bounded by neither that limit nor the 1,000-iteration loop cap, and the test counts lines outside the runtime so an undercount fails instead of rounding down into a plausible number. Verified by reverting the implementation: the test fails 1275 vs 2053.

One finding was declined: naming the member in the runtime Java arity path. Codex is factually right that details.reference_id is available there, but the plan scopes runtime arity out in writing — naming accepted arities needs overload lookup plus receiver-inclusive arity normalisation. Recorded as a residual, with the note that the member name is separable from the arity work.

The last fix landed after the third round, so it carries mix precommit and a reverted-implementation check rather than a fourth review.

Documented residual

Recognition does not fully fail closed. The optional tool-envelope sentence is not validated, so arbitrary text inserted immediately before the map-type legend preserves every anchor, the ordering and the rejoin invariant, and is accepted as part of api-legend. Reaching that state requires a manifest whose namespace docstrings end exactly at that position.

Recorded rather than fixed because it cannot move the gated number: the gate reads total on a pinned fixture, and segment attribution does not enter that measurement. It can only mislead the reporting rows, which are already documented as approximate. Closing it would mean validating that span as exactly either the 82- or 254-character form — cheap, but it reintroduces literal-matching into the parser for a reporting-only benefit, which is the trade this plan already made once in the other direction.

Verification

mix precommit green: format, compile, cycles, stable-CLI callers, credo (no issues), duplication (new=0), spec validation 100%, generated-artifact staleness, and the root, Viewer and launcher suites. No new :e2e tests — neither slice reaches a provider.

The shipped system prompt is the one authored surface in the tree with no
authoring budget. Runtime limits bound one run, not the source, and no check
prices an edit: PR #1310 grew the instruction text twice, including the time
it was repaired by deleting a clause, because there was nothing to measure
with.

prompt.audit is a new pure prelude taking a string. segments splits a
rendering into its ordered emission segments and rejoins to the input exactly;
measure sizes each one plus authored, dynamic and total rows; delta reports the
per-segment character change between two versions.

Recognition fails closed, because manifest text reaches the prompt unescaped.
A docstring that reproduces an anchor, a duplicated API-notes heading, or an
entry list truncated by even one character all yield one unrecognised segment
rather than a guess.
The notes segment ends at the last blank line before the legend rather than the
first after the heading: a docstring may contain a blank line, and ending there
would cut manifest text off mid-segment and report its remainder as authored —
the one way a reporting row can state the opposite of the truth rather than
approximate it.

Because the input is a string, one implementation serves the REPL reading a
recorded run, the tests guarding the committed artifacts, and the gate. Two
artifacts are committed from a canonical fixture mission that renders every
segment at once; the gate asserts recognition first, then a character ceiling
on the final-turn total. It reads total rather than authored because the
dynamic segments carry authored formatter text, so an authored ceiling would
not move when render-entry grows.

Registering the component changes the declared callable surface, so
inspection-analysis-v2 becomes v3. AnalysisResources accepts the profile ID as
a literal and would have failed every v3 capture with :invalid_analysis_resources
had it been missed; the full set was re-derived by repository search rather than
taken from a list, which also caught analysis_session_builder.ex.

Line counting uses neither loop/recur nor a regex. Evaluation caps loops at a
thousand iterations, and the regex runtime truncates its input at 32,768 bytes
and undercounts silently rather than failing — both would have misreported
exactly the large mission inventories most worth measuring, one loudly and one
quietly. Sequence functions are bounded by neither, and the test counts lines
outside the runtime so an undercount fails instead of rounding down into a
plausible number.

No prompt wording changes here — the instrument lands independently of the
thing it will be used to measure.

Verified with mix precommit: format, credo, duplication (new=0), spec
validation, generated-artifact staleness and the root, Viewer and launcher
suites.
A failed name lookup should take the highest rung it can reach: name one
alternative verified to exist, list the bounded set the name must have come
from, or point at search. The first two cost the model no turns and the third
costs one, and no rung may print a name that was not resolved first.

The hint ladder lived in format_closure_error/1, which bare symbols never
reach: check_undefined_var_candidates/2 intercepts every statically visible
undefined name before evaluation, so the model saw a bare "Undefined variable:
x" and none of the ladder. The ladder now lives in one function that both
producers call, which is what makes these changes observable at all.

- Unresolved bare name: points at (apropos "name") and states that it searches
  prelude exports, not builtins, lexical bindings, or earlier definitions.
- Underscore names: the hyphenated candidate is looked up in Env.initial/0 and
  suggested only when it resolves, then the verified fuzzy match, then the
  naming convention with no name asserted. map_indexed still yields
  map-indexed; zzz_qqq_www no longer yields zzz-qqq-www.
- Unsupported Java class: lists admitted class names derived from the distinct
  class_ids of Surface.references/0, not Surface.class_spellings/0, which
  flat-maps inventory-only classes and constructor spellings such as
  "java.util.Date." — neither callable.
- Java arity error on the analysis path: names a canonical admitted spelling
  and the accepted arities instead of the internal reference_id atom, resolving
  through fetch_reference/1 and, for direct-dot failures, member_family_source/1.
  A field called as a function has no admitted arity at all, so it is named as
  a field rather than reported as accepting an empty one.
- Receiver-class mismatch: a type failure, not a lookup, so it names the member
  and stops. The receiver profile stays structured detail; :unsupported is not
  a type the model could act on.
- @clojure_alternatives said import was unavailable because there is "no Java
  interop", which the admitted bounded surface contradicts. refer, defmacro,
  eval and read-string were missing entirely, so all four collected search
  advice for functionality absent by category. The format, re-find and re-seq
  entries are deleted: all three are builtins, so the entries were unreachable
  and asserted that three available functions were unavailable — and the
  re-find entry offered `grep`, which has never existed. A test now holds both
  halves of the invariant: every key is genuinely unbound, and every name the
  hints offer resolves.

Authority refusals keep the carve-out: listing the tools a caller does hold is
fine and already happens; listing what a denial hides is not. Asserted per
clause rather than as a blanket rule.

The rollback statement goes in agent.feedback rather than any evaluator
condition. agent.feedback/success tells the model its definitions survived and
no failure path stated the converse; only one of the three failure paths
carries details.message, so a sentence written into a condition would reach the
model on one path in three. Added to all three, with an integration test each.

Residuals recorded, not fixed: runtime Java arity still falls through the
generic clause, which needs overload lookup and receiver-inclusive arity
normalisation rather than a message edit; lisp.ex's catch-all still renders a
raw Elixir tuple through inspect/2; and dispatch.ex's unresolved-reference and
kind-mismatch conditions remain untouched as internal invariants, since they
signal a runtime bug rather than a model mistake.

Verified with mix precommit, green including root, Viewer and launcher suites.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant