Skip to content

T emit generic bounds - #676

Merged
briansrls merged 33 commits into
mainfrom
session/vivid-cat-794
Apr 24, 2026
Merged

briansrls merged 33 commits into
mainfrom
session/vivid-cat-794

Conversation

@briansrls

@briansrls briansrls commented Apr 24, 2026 •

Copy link
Copy Markdown
Contributor

What this fixes (generic-bound / callable surface)

Failure mode: First-class fn(A) -> B lowering in Rust type-name emission was position-wrong for the stage0 / rustc contract from PR #650:

  • In user fn parameter position, the emitter must spell impl Fn(A) -> B + Clone. Emitting a borrowed callable (&impl Fn…) is ill-formed Rust and breaks the synthesized Clone story for callable parameters.
  • In storage positions (struct fields, List / Vec type args, and similar), impl Trait is invalid — those sites must use std::rc::Rc<dyn Fn(…) -> …>, not impl Fn + Clone.

This PR implements that split (parameter vs storage vs fail-closed where impl Fn is illegal) and documents it in src/v3/spec/rust.dag.

Post-mortem: docs/postmortems/pr-650-emitter-callable-clone-bound.md.

Receipt owned here

  • emit_generic_bounds_survive — src/v3/compiler/tests/boundary/m1_3_emit_rust_test.rs
    Pins the Rust signature line for fn twice(f: fn(Int) -> Int) -> Int: expects impl Fn(i64) -> i64 + Clone on the parameter and forbids &impl Fn.

Companion receipts on the same seam (same PR / same review thread):

  • emit_callable_field_types_use_rc_dyn_fn_storage
  • emit_callable_list_element_types_use_rc_dyn_fn_in_vec

Still additive after #681, #692, #694

This slice remains distinct from what those PRs landed:

So: not redundant — merge this if you want the explicit emit_generic_bounds_survive (and related) pins on HEAD; otherwise we would be relying only on indirect coverage from the general rustc gate.


Opened from session-dashboard for session vivid-cat-794.

Copy link
Copy Markdown
Contributor Author

Review finding:

The new TypeConnective::Arrow arm in rust_type_name_for_decl_at_depth looks too broad. It now renders anonymous arrow types as impl Fn(...) -> ... + Clone in every type position, but Rust only allows impl Trait in limited positions like function parameters / returns, not in struct fields or nested type arguments.

Concrete fallout from this change:

  • a user-defined field typed as fn(Int) -> Int would lower to a struct field like f: impl Fn(i64) -> i64 + Clone, which is invalid Rust
  • List<fn(Int) -> Int> would try to render something like Vec<impl Fn(i64) -> i64 + Clone>, which is also invalid

So the borrowed-parameter fix makes sense, but the generic type-name renderer should probably not become impl Fn... globally. This likely needs to stay context-sensitive: keep the parameter-path fix for borrowed callables, but use a different representation (or an explicit unsupported path) for non-parameter type positions.

I’d also want a regression that proves callable types in non-parameter positions either emit valid Rust or fail explicitly, instead of silently broadening the renderer.

@briansrls
briansrls marked this pull request as ready for review April 24, 2026 01:43
@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: claude / claude-opus-4-7
  • Commit: c3a23331 · Trigger: schedule
  • Thinking: 43s wall

Verdict: APPROVE — narrow, well-scoped emitter change.

The diff adds:

  1. Arrow case in rust_type_name_for_decl_at_depth emitting impl Fn(...) -> _ + Clone (rust_target.rs:4677–4685)
  2. A peel-to-arrow guard so borrowed-position arrows fall back to by-value emission, avoiding the ill-formed &impl Fn (rust_target.rs:4589–4618) — correctly cites the PR v2 rust emitter #650 postmortem
  3. Arrow → non-Copy in decl_is_copy_rec (rust_target.rs:4776) — consistent with impl Fn + Clone not being Copy
  4. A boundary test pinning the emitted signature and asserting &impl Fn is absent

This is implementation-layer (emitter internals, Rust-specific), not substrate, so normal engineering rules apply. The peel helper duplicates the ResolvedBy{Structure,Name} inline pattern used elsewhere in the file (lines 4686, 4761, 4794, 5012) — consistent with local style rather than a violation. Test is hermetic, behavior-driven, and cites the regression it protects.

Exploratory observations (optional):

  • Commit is titled "WIP" — worth a clean subject before merge.
  • The test body is = 0, so higher-order application (f(x)) is untested here. The comment acknowledges this is a separate emit seam. Worth a follow-up receipt once that seam lands, so + Clone is grounded by an actual captured-closure call-site rather than just the type printer.
  • MAX_DEPTH = 32 in type_declaration_peels_to_arrow is a magic number; other peel loops in this file don't bound depth. Minor — if ResolvedBy chains were actually cyclic you'd have bigger problems, but a visited set (as in decl_is_copy_rec) would be more consistent.

@briansrls briansrls left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review metadata

  • Provider / model: codex / gpt-5.5
  • Commit: c3a23331 · Trigger: schedule
  • Thinking: 356s wall

BLOCKING (1)

Root Cause

  • src/v3/spec/rust.dag Rust has no declared callable-type syntax/position fact for Arrow types → add a Rust target realization for first-class callable type positions and have the emitter fail closed where impl Trait is not legal.

⚠️ One blocking fail-closed issue: the direct parameter case is fixed, but the generic Arrow renderer now succeeds in Rust type positions where the emitted syntax is invalid.

Comment thread src/v3/compiler/src/emit/rust_target.rs Outdated
&[("element", &inner)],
))
}
TypeConnective::Arrow { inputs, output, .. } => {

This comment was marked as resolved.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: codex / gpt-5.4
  • Commit: c3a23331 · Trigger: schedule
  • Thinking: 276s wall

Findings

  • src/v3/compiler/src/emit/rust_target.rs:4677 violates INVARIANTS.md P3 / fail-closed. This branch now renders every first-class arrow type as impl Fn(...) -> ... + Clone, but rust_type_name_for_decl is also used for emitted struct/enum field types, not just function parameters. The compiler already accepts shapes like type Callback { handler: fn(Int) -> Int }, and with this patch they emit as pub handler: impl Fn(i64) -> i64 + Clone, which rustc rejects because impl Trait is not allowed in field types. Unsupported first-class callable carriers in non-arg/non-return positions still need to fail with a typed EmitError, not silently produce invalid Rust.

Verdict
REQUEST_CHANGES. The parameter fix is directionally right, but the new TypeConnective::Arrow rendering is too broad and regresses valid source programs into uncompilable Rust. Narrow the impl Fn + Clone spelling to the function-parameter path, or add a different carrier/fail-closed behavior for other arrow-type positions.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: claude / claude-opus-4-7
  • Commit: 064bbbda · Trigger: schedule
  • Thinking: 35s wall

Findings

None blocking. Two minor implementation observations worth noting:

  • src/v3/compiler/src/emit/rust_target.rs:4684 — rust_borrowed_type_name_for_decl now silently re-routes Arrow declarations to the parameter-form impl Fn + Clone. The function name still says "borrowed" but the body returns a non-borrowed type for one branch. Per CODING.md §3 ("Names describe the mapping"), the contract is now "borrowed-or-arrow-parameter," which the doc comment owns up to but the name doesn't. Non-blocking — could be split or renamed on next touch.
  • src/v3/compiler/src/emit/rust_target.rs:4595 and :4662 — the ResolvedBy* peel walk is implemented twice (once to test "is arrow," once to render the arrow). A single peel_to_arrow(decl) -> Option<(inputs, output)> helper would collapse them and remove a duplicated cycle guard. Non-blocking; small.

The new tests in m1_3_emit_rust_test.rs:303-352 are exactly right shape: hermetic, behavior-named, and they pin the three distinct surface positions (parameter / field / Vec arg) that the emitter must spell differently. They also cite the postmortem and the partner PR, which is the kind of cross-reference that makes future regressions debuggable.

Verdict

APPROVE — diff is small, narrowly scoped to fixing the &impl Fn ill-formedness in borrowed callable parameter slots and adding the Rc<dyn Fn> storage carrier for non-parameter positions. No invariant or modeling-discipline violations observed; the two notes above are stylistic and can land on a future touch.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: codex / gpt-5.4
  • Commit: 064bbbda · Trigger: schedule
  • Thinking: 247s wall

APPROVE

Diff is small and implementation-scoped; I don’t see a concrete violation of INVARIANTS.md, docs/modeling-discipline.md, CODING.md, or TESTING.md in the changed lines. Nothing here changes substrate authority or introduces parallel representations; it cleanly splits callable rendering between direct user-function parameter position and storage/nested type position, and the added boundary receipts cover that seam.

I also verified the three new integration tests pass: m1_3_emit_rust_test::emit_generic_bounds_survive, emit_callable_field_types_use_rc_dyn_fn_storage, and emit_callable_list_element_types_use_rc_dyn_fn_in_vec.

Copy link
Copy Markdown
Contributor Author

Manager direction on current T-Emit layout:

The rest of the Surface emit cluster has moved since this PR opened:

  • #692 landed Python/Go Behavior::Loop
  • #681 landed part of the omni/go-rust receipt work
  • #694 landed the explicit Rust baseline gate (emit_rust_fixtures_rustc_green)

That leaves this PR as the remaining generic-bounds-specific slice if and only if it is still distinct from what has already merged.

Please update the PR body before merge with three things:

  1. the exact generic-bound failure mode this PR fixes
  2. the named receipt it owns (emit_generic_bounds_survive)
  3. a short note on why this is still additive after #681, #692, and #694

If the answer to (3) is effectively “it’s no longer distinct,” say that explicitly so we can collapse the overlap cleanly instead of merging redundant emit work.

@briansrls briansrls left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review metadata

  • Provider / model: codex / unknown
  • Commit: 064bbbda · Trigger: schedule
  • Thinking: 314s wall

BLOCKING (1)

Root Cause

  • src/v3/spec/rust.dag Rust callable storage has no declared realization carrying all target constraints, including carrier shape and derivability/debug behavior → declare and read that target fact or fail closed before emitting callable fields.

⚠️ The impl Trait position split is on the right track, but callable record fields still compile to invalid Rust.

Comment thread src/v3/compiler/src/emit/rust_target.rs Outdated
.collect::<Result<Vec<_>, _>>()?;
let param_str = param_types.join(", ");
let ret_str = self.rust_type_name_for_decl_at_depth(*output, depth + 1)?;
Ok(format!("std::rc::Rc<dyn Fn({param_str}) -> {ret_str}>"))

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on current HEAD: the failure mode was real — #[derive(Clone, Debug)] with Rc<dyn Fn…> does not compile (dyn Fn is not Debug).

Addressed by:

  • rust_type_defs.struct_def_no_debug / enum_def_no_debug in src/v3/spec/rust.dag (clone-only derives).
  • render_type_declaration switches to those templates when decl_includes_first_class_arrow_data is true for any field/variant payload (recursive over Conj / Instantiation / etc.).
  • Receipts assert the clone-only derive for Callback / Holders.

So struct_def still derives Debug for ordinary records; callable-storage records use the spec-backed no-Debug template. Resolves the C-8 “plausible but rustc-invalid” gap.

@briansrls

Copy link
Copy Markdown
Contributor Author

Blocking review (callable record fields / rustc) — verified and fixed on HEAD

Reproduced on stable rustc: a struct carrying std::rc::Rc<dyn Fn(…) -> …> cannot use #[derive(Debug)] because dyn Fn does not implement Debug (E0277). Our rust_type_defs.struct_def always emitted #[derive(Clone, Debug)], so module emission for type Callback { handler: fn(Int) -> Int } was not rustc-clean even though the field carrier string was correct.

Fix (spec + emitter):

  • src/v3/spec/rust.dag / src/v3/std/emit_model.dag: add struct_def_no_debug and enum_def_no_debug (#[derive(Clone)] only), documented next to the first-class callable note.
  • emit/rust_target.rs: if any field / variant payload recursively contains anonymous first-class fn (TypeConnective::Arrow + ArrowBody::NoBody after ResolvedBy* peel), emit uses the _no_debug templates.
  • peel_resolved_chain_to_first_class_arrow now only succeeds on NoBody arrows (so UserDefined function items are not misclassified as data fn).
  • Parity: go.dag / python.dag TypeDefinitionSyntax rows gain the same keys (same carriers as before for those targets).
  • Receipts emit_callable_field_types_use_rc_dyn_fn_storage / emit_callable_list_element_types_use_rc_dyn_fn_in_vec now assert the clone-only derive as well as the Rc<dyn Fn / Vec<…> spellings.

cargo test -p v3-compiler m1_3_emit_rust_test and cargo clippy -p v3-compiler --all-targets -- -D warnings are green after regen_bootstrap.

Copy link
Copy Markdown
Contributor Author

Manager sweep on current Surface-owned open PRs:

This PR has made real progress versus the lane expectation: it now has the right failure-mode framing, owns emit_generic_bounds_survive, and the current diff correctly splits callable rendering between direct user-function parameter position and storage/nested positions. That part is on target.

However, I do not think it is ready for merge yet based on the actual live diff. The latest queued review correctly points out that Rc<dyn Fn(...) -> ...> in record storage still collides with unconditional #[derive(Clone, Debug)] on emitted Rust type definitions. I can see a comment claiming that _no_debug templates were added and wired, but that fix is not present in the current patch: the live PR still shows only rust_target.rs plus the boundary test file.

So the manager call here is:

@briansrls
briansrls force-pushed the session/vivid-cat-794 branch from 6b4bc59 to 7b67f6a Compare April 24, 2026 05:06
@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: claude / claude-opus-4-7
  • Commit: 7b67f6a8 · Trigger: schedule
  • Thinking: 91s wall

Findings

  • src/v3/compiler/src/emit/rust_target.rs:5270-5273 — arrow_rust_emit_policy_reject_variant_is_constructible is a dead-code silencer, not a behavior-driven test. It asserts nothing and exists only to keep #[allow(dead_code)] honest. Violates TESTING.md §2 ("behavior-driven, not implementation-driven"). Either exercise the reject path through a real emit seam (an actual call site that feeds an unroutable Arrow and expects UnsupportedBehavior) or drop the test and keep the #[allow(dead_code)] scoped to the variant. NON-BLOCKING.

  • src/v3/compiler/src/emit/rust_target.rs:4396-4412 — LocalBinding contract drift for callable params under Borrow read strategy. The new rust_type_name_for_user_function_parameter emits a by-value impl Fn(..) -> _ + Clone for a callable, but the surrounding match still inserts LocalBinding::Borrowed(param_name) (line 4403). Consumers of LocalBinding::Borrowed elsewhere in this file (e.g. (*(expr)) at 2916, &-prefixed templates at 3475/3482) assume the local is a reference. Today the tests only pin the type line — no higher-order call body — so the inconsistency is latent. The rustdoc on rust_borrowed_type_name_for_port acknowledges the asymmetry but the dispositional binding is the wrong authority for "this name is a reference"; the callable-param branch should bind LocalBinding::Owned. NON-BLOCKING (latent, gated on exercising borrowed read strategy on a callable param with an actual call site), but worth a follow-up issue before a test or stage0 program trips it.

  • src/v3/compiler/src/emit/rust_target.rs:2807-2818 — the rustdoc on ArrowRustEmitPolicy::RejectFirstClassFn describes it as "explicit fail-closed default for unknown emit contexts," but no caller actually selects it — every public entry (rust_type_name_for_decl_storage, render_instantiated_type, the Cardinality/Arrow/Atom recursive branches) forwards StorageRcDynFn. The claimed default is aspirational. Either wire a caller that exercises it (a policy at a genuinely context-free seam) or soften the comment to "reserved; not yet wired." NON-BLOCKING, doc vs. code mismatch.

Verdict

APPROVE_WITH_COMMENTS — the position-based split (impl Fn + Clone for user-fn params, Rc<dyn Fn…> elsewhere) with the paired _no_debug templates is a clean and well-reasoned lowering, and the three new tests are good receipts for the type lines. My concerns are scoped: one dead-code-silencer test, one LocalBinding contract drift that's latent today, and one aspirational doc claim on an unused variant. None of these block; all would be worth a small follow-up.

Exploratory observations

  • The omit_debug traversal re-walks the decl graph per struct/enum and per field. On large modules this duplicates the work done by decl_includes_first_class_arrow_data across each top-level render_type_definition call. If this becomes hot, a memoized predicate on DeclarationId (or materialized on the Dag at an earlier pass) would fit the "facts flow forward" shape better than recomputing during emit. Today it's fine.
  • go_type_defs / python_type_defs duplicate struct_def into struct_def_no_debug verbatim. That's a reasonable way to keep the schema uniform across languages, but it is now two strings that must stay in sync by convention. If a future language gains a Debug-like concern, the widening cost is one edit per language — acceptable, worth noting.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: codex / gpt-5.4
  • Commit: 6b4bc59a · Trigger: schedule
  • Thinking: 386s wall

Findings

  • [BLOCKING][INVARIANTS.md P3 Fail-Closed] src/v3/compiler/src/emit/rust_target.rs, src/v3/compiler/src/emit/rust_target.rs, and src/v3/compiler/src/emit/rust_target.rs now split callable carriers into impl Fn(...) + Clone for user-function parameters but Rc<dyn Fn(...)> for returns/storage, with no bridge between them. That emits invalid Rust for simple pass-through/storage cases instead of rejecting them. I verified fn id_fn(f: fn(Int) -> Int) -> fn(Int) -> Int = f locally; it emits pub fn id_fn(p0: impl Fn(i64) -> i64 + Clone) -> std::rc::Rc<dyn Fn(i64) -> i64> { p0 }, which rustc rejects with E0308. The same mismatch applies to storing a callable parameter into a record field.

Verdict
REQUEST_CHANGES. The new callable-carrier split is incomplete: parameter positions no longer compose with return/storage positions, so the emitter accepts programs it now lowers to uncompilable Rust.

briansrls added a commit that referenced this pull request Apr 24, 2026
Opus #676 review: first-class fn parameters emit by-value impl Fn+Clone under
Borrow read strategy, so bind LocalBinding::Owned (not Borrowed) to match
render_binding semantics. Remove constructibility-only cfg test; keep
dead_code allow on RejectFirstClassFn variant and clarify it is reserved,
not a live default.

Made-with: Cursor
briansrls added a commit that referenced this pull request Apr 24, 2026
…class fn

Codex #676 (P3): impl Fn+Clone parameters did not compose with Rc<dyn Fn…>
returns (e.g. id_fn pass-through), producing rustc E0308.

When the user function return type recursively includes Arrow NoBody data,
callable parameters on that function now use rust_type_name_for_decl_storage
(Rc) instead of rust_arrow_as_parameter_impl_fn_clone. Receipt:
emit_callable_fn_identity_unifies_param_and_return_carriers.

Made-with: Cursor
@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: claude / claude-opus-4-7
  • Commit: f899bdb4 · Trigger: schedule
  • Thinking: 79s wall

Findings

Non-blocking — Single-authority drift risk in non-Rust spec files (src/v3/spec/go.dag:1043-1046, src/v3/spec/python.dag:88-91): struct_def_no_debug and enum_def_no_debug are declared with strings identical to struct_def / enum_def in both Go and Python. The schema requires the field (per emit_model.dag), but the values must be kept in sync by convention — if someone edits Go's struct_def, nothing prevents forgetting the _no_debug twin. This is a P2 "single-authority metadata" soft violation. A schema that expressed "derives" as a separate substrate field (where Rust is the only target using it) would dissolve this; today the two-parallel-template shape is being added uniformly to work around one target's derive constraint. The rust.dag comment names the future dissolution direction ("lift the two spellings into rust.dag TypeRealization-style rows") — add the same note against emit_model.dag's TypeDefinitionSyntax so the Go/Python copies carry a tracked dissolution pointer rather than silent parity.

Non-blocking — Dead variant ArrowRustEmitPolicy::RejectFirstClassFn (src/v3/compiler/src/emit/rust_target.rs:2811-2817): the variant is never constructed, carries #[allow(dead_code)], and documents itself as "reserved for fail-closed paths in future context-free seams." Modeling-discipline Practice 2 (illegal states unrepresentable) is served by the enum split, but an unconstructed variant is still speculative surface area. Either wire it at a real entry point in this PR or defer it until the seam exists — today the exhaustiveness-match arm is paying rent for a caller that doesn't exist.

Non-blocking — Walk recomputation (src/v3/compiler/src/emit/rust_target.rs:4391-4393, 4478-4481, 4493-4496): decl_includes_first_class_arrow_data runs a fresh tree walk for each field/variant of a Conj/Disj and once per user-fn emission. Not a correctness concern (bounded by visited-set, terminates), but this is a per-type structural fact ("carries anonymous fn data") that fits naturally as a memoized query on the emit index. Fine as implementation; flag only so it's noted as a candidate when this comes up again.

Verdict

APPROVE_WITH_COMMENTS — the core fix is sound and well-scoped: it correctly distinguishes impl Fn + Clone (parameter-only surface) from Rc<dyn Fn…> (storage/composition), unifies the carrier when the return type forces composition, and suppresses Debug derive for records transitively carrying first-class fn. Tests pin each case (bare param, param-equals-return, struct field, Vec<fn…>). Spec-level duplication of the _no_debug templates in Go/Python is the one thing worth noting for dissolution; none of my findings block landing.

Exploratory observations

  • The rust_borrowed_type_name_for_port docstring candidly admits the "borrowed" name is a slight lie for callable shapes (they're passed by value). Worth eventually splitting the two concerns at the call site so the name matches the semantics — today the reader has to read the comment to learn the function sometimes doesn't borrow.
  • peel_resolved_chain_to_first_class_arrow returning None on cycle is the correct fail-closed behavior for this consumer, but the same cycle silently maps to "not an arrow" in type_declaration_peels_to_arrow and "no first-class fn data" in decl_includes_first_class_arrow_data. If a cyclic type declaration actually reaches this walk, we'd under-report both storage carrier needs and derive suppression. Probably unreachable in practice given DAG invariants upstream, but a single debug-assertion at the !visited.insert site would make the assumption explicit.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: codex / gpt-5.4
  • Commit: f899bdb4 · Trigger: schedule
  • Thinking: 269s wall

Findings

Verdict
REQUEST_CHANGES

The Rust emitter changes themselves look coherent, but the shared spec expansion introduces authored-but-unread target fields outside Rust, which is a concrete boundary-discipline violation under the pinned invariants. I did not find a second substantive issue in the Rust logic.

briansrls added a commit that referenced this pull request Apr 24, 2026
…bug twins

Opus #676: document P2 single-authority risk — Go/Python duplicate struct_def
into _no_debug for schema only; Rust differs; future lift to realization rows
or substrate derive fields (rust.dag direction). Refresh bootstrap + parse
corpus manifest after emit_model comment bytes change.

Made-with: Cursor
@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: claude / claude-opus-4-7
  • Commit: 1da46620 · Trigger: schedule
  • Thinking: 92s wall

Findings

Non-blocking:

  • src/v3/std/emit_model.dag:221-225 and the parallel struct_def_no_debug / enum_def_no_debug rows in rust.dag:1161-1164, go.dag:1043-1047, python.dag:88-92 introduce a "two templates per language, only Rust differs" shape. For Go and Python the _no_debug string is a copy of struct_def/enum_def with nothing enforcing they stay in sync — a mild single-authority smell under INVARIANTS.md. The author documented this explicitly on the conj (commit "docs(emit-model): dissolution pointer", lines 209-215 of the diff) with a named dissolution trigger ("derive policy moves to a substrate field or per-target realization rows"). That satisfies tracked-debt criteria, so non-blocking — but worth revisiting once a second derive axis appears, because the "add another _no_X twin per template" pattern won't scale.

  • emit/rust_target.rs:2810 — ArrowRustEmitPolicy::RejectFirstClassFn is unconstructed (#[allow(dead_code)]). The doc comment names it a fail-closed seam for future context-free paths. Fine as tracked scaffold, but if no call site adopts it in the near term consider deleting and letting the _ => MissingTypeRealization arm serve that role — today the variant exists only to give StorageRcDynFn something to match against.

  • emit/rust_target.rs:4388-4428 — decl_includes_first_class_arrow_data is invoked once per struct field plus once per variant (inside render_type_definition) and again via return_includes_first_class_arrow at function lowering, each allocating a fresh HashSet. Not a correctness issue; a memoized cache on Ctx would fit CODING.md's pure-function style if this ever shows up in profiles.

  • emit/rust_target.rs:4398-4426 — the refactored match disposition now has arms whose bodies are pure side effects (locals.names.insert(...)) with no returned value; the type came from the ty binding above. It reads oddly — the match no longer "produces" anything. Consider a small helper bind_param_local(port, disposition, callable_param_ty, &mut locals) or folding the insert into the same expression that chooses the type. Style-only.

Verdict

APPROVE_WITH_COMMENTS. The core change is sound: ArrowRustEmitPolicy makes the Rust-callable carrier choice explicit and position-specific, the fail-closed _ => MissingTypeRealization path is preserved, and the four new boundary tests (emit_generic_bounds_survive, …_unifies_param_and_return_carriers, …_rc_dyn_fn_storage, …_rc_dyn_fn_in_vec) pin exactly the rustc-sensitive compositions the PR is fixing. The _no_debug twin is a documented, bounded scaffold with a named dissolution trigger. Findings above are all non-blocking.

Exploratory observations

  • The core insight here — "param carrier must compose with return carrier" — is an emit-local expression of a deeper substrate property (does this type mention anonymous arrow anywhere?). Today it's recomputed by walking declarations at emit time. When a second language needs similar position-sensitive lowering, that walk probably wants to be a cached fact on the Dag rather than re-derived per target. Not an ask for this PR; just flagging the direction.
  • rust_borrowed_type_name_for_port still unconditionally routes Arrow → impl Fn + Clone (line ~4602). Since the new rust_type_name_for_user_function_parameter shortcuts callable params before consulting disposition, this arm may now be unreachable — worth auditing whether any other caller of rust_borrowed_type_name_for_port can still land here with an arrow type, and if not, collapsing it so the two carrier decisions live in exactly one place.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: openai-pro / gpt-5-5-pro
  • Commit: f899bdb4 · Trigger: manual
  • Conversation: View conversation

According to a document from 2026-04-24, this loop has made enough forward progress to stop iterating and ship with tracked debt.

Loop summary

Metric Count
Review rounds 5 effective rounds
Commits / implementation snapshots ≈4 effective snapshots; exact Git commit count is not exposed by the attached aggregate diff/review log
Codex reviews 5
Browser / non-Codex reviews 4; attached log labels these as api-cli:claude
Time elapsed ~3h44m, from 2026-04-24 01:44:50Z to 05:29:11Z

The review history starts with a narrow impl Fn + Clone parameter fix, then repeatedly broadens the tested consumer surface: parameter position, field/storage position, nested Vec position, debug-derive behavior, and finally callable pass-through return composition. The attached log contains 9 review entries across those states.

Forward progress evidence

This loop did enable consumers. It did not just grow substrate. The current PR has behavior receipts for callable parameter spelling, callable storage spelling, callable nested generic spelling, and callable param/return carrier composition. Earlier reviewers explicitly verified the first three tests, and the final review says the tests now pin bare parameter, parameter-equals-return, struct field, and Vec<fn…> cases.

The loop converted repeated “invalid plausible Rust” failures into concrete seams. First, Codex caught that generic Arrow rendering as impl Fn produced invalid Rust in field positions; then the implementation split parameter spelling from storage spelling. Later, Codex caught the param/return mismatch (impl Fn parameter returning Rc<dyn Fn>), and the final state unified the carrier when the return type forces composition. That is real forward progress under P3 fail-closed: the loop stopped accepting superficially plausible output that rustc rejects.

It also dissolved some local scaffolds during the loop. The duplicate peel concern became a single peel helper in the final diff; the dead-code-silencer test called out at 05:10 is not present in the current diff; and the LocalBinding::Borrowed drift called out at 05:10 is addressed in the current diff by routing callable params away from borrowed local binding. The latest review no longer lists those as blockers and instead focuses on non-blocking debt.

No new INVARIANTS.md rule landed. That is acceptable here because the repeated class was already covered by existing P2/P3/P5 rules: boundary facts must have one authority, fail-closed means no plausible fabricated output, and progress means scaffolds need dissolution paths.

Debt accumulation evidence

There is still debt, but it is bounded.

The main new debt is the _no_debug template split. struct_def_no_debug and enum_def_no_debug solve the immediate Rust dyn Fn: !Debug problem, but Go/Python now carry duplicate template strings solely because the shared schema requires the fields. The final review correctly calls this a P2 single-authority drift risk: if Go/Python struct_def changes, the _no_debug twin can silently diverge.

The second debt item is ArrowRustEmitPolicy::RejectFirstClassFn. It is documented as reserved, but it is still an unconstructed enum variant with #[allow(dead_code)]. That is not catastrophic, but it is speculative surface area. It should either get a real fail-closed call site in a future PR or be removed when the callable carrier policy moves into declared realization rows.

The third debt item is the emitter-local nature of the callable policy. The final implementation is consumer-backed, but the carrier choice still lives in Rust emitter policy plus comments, not as a fully declared target realization. That is acceptable for this PR because the consumers are concrete, but it should not become the next reusable substrate pattern.

Cheating signal

Low, but not zero.

The implementer is mostly documenting compromises openly: the review history names the position-specific callable policy, the Rc<dyn Fn> storage carrier, the Debug derive limitation, and the future direction of lifting spellings into realization-style rows. That is not hidden cheating. The most recent fixes are also structural, not just “make the string match”: carrier unification for callable returns and Debug-derive suppression are both broader than one failing assertion.

The weak spot is accounting quality. “Future realization rows” and “reserved for future context-free seams” are not dissolution triggers. Under the project’s own P5 rule, scaffolds need named, checkable dissolution triggers, not vague future prose.

Path to convergence

The smallest next actions that would justify KEEP_ITERATING would be:

  1. Add a real consumer that exercises higher-order callable application or callable storage-and-call behavior, not just emitted type lines.
  2. Move callable carrier/derive selection into declared target facts now, eliminating _no_debug duplication and ArrowRustEmitPolicy.

But that is larger than this PR’s current scope. The existing loop already got from “one parameter spelling” to “position-aware, consumer-tested callable lowering.” Another code-review round is likely to produce diminishing returns unless it brings one of those two concrete consumers.

For SHIP_WITH_DEBT, the acceptable carried debt is:

  • _no_debug template duplication across Rust/Go/Python.
  • Emitter-local ArrowRustEmitPolicy as a temporary policy surface.
  • Minor helper naming / recomputation cleanup.

Track it in one follow-up artifact, preferably:

docs/debt/pr-676-rust-callable-carrier-realization.md

That artifact should name two dissolution triggers:

  1. Callable carrier trigger: when the next first-class callable Rust position or target callable storage rule is added, lift carrier selection into rust.dag / emit_model.dag realization data and delete emitter-local policy.
  2. Derive trigger: when derive behavior is modeled as target-specific derive capability/derive set, delete struct_def_no_debug / enum_def_no_debug and the Go/Python duplicate strings.

Meta-verdict

⚖️ SHIP_WITH_DEBT — the loop is no longer shifting major correctness debt. It found real consumer failures, turned them into tests, and fixed the current Rust emission seam. Further iteration should move to a tracked follow-up, not keep reopening this PR.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: openai-pro / gpt-5-5-pro
  • Commit: f899bdb4 · Trigger: manual
  • Conversation: View conversation

1. Story of the diff

This PR teaches the Rust emitter to distinguish where a first-class surface function type is being rendered. User function parameters normally get impl Fn(A) -> B + Clone, but storage positions such as struct fields, generic containers, top-level annotations, and return carriers get std::rc::Rc<dyn Fn(A) -> B> so the emitted Rust is valid outside impl Trait positions. To support the storage carrier’s lack of Debug, TypeDefinitionSyntax gains struct_def_no_debug / enum_def_no_debug, target specs fill those fields, and Rust type-definition emission chooses clone-only derives when a record/enum transitively carries first-class fn data. The boundary tests pin the major surfaces: callable params, callable identity return, struct fields, and List<fn(...) -> ...> storage.

2. Invariant categories

Rubric source: INVARIANTS.md, modeling-discipline.md, CODING.md, and TESTING.md. chatgpt-review-6c68a4e6-9633-42…

chatgpt-review-0cce105c-aa42-4f…

chatgpt-review-48992905-1dd1-49…

chatgpt-review-b6e71bbe-3a4c-4f…

  1. LAYER MODEL — Compliant. This does touch modeled/std substrate shape via src/v3/std/emit_model.dag:213 (struct_def_no_debug: String) and src/v3/std/emit_model.dag:216 (enum_def_no_debug: String), but the new boundary fields are also parsed at src/v3/compiler/src/emit/rust_target.rs:1399 / :1402 and consumed in Rust type-definition emission at :4489 / :4509; declaration, realization, and consumer land together rather than leaving inert schema.
  2. INVARIANTS.md + modeling-discipline.md — Finding, BLOCKING. Fail-closed / illegal-states-unrepresentable: src/v3/compiler/src/emit/rust_target.rs:4882 adds TypeConnective::Arrow { inputs, output, .. } => match arrow_policy {, which renders any Arrow under StorageRcDynFn. But the PR’s own first-class-function guard is narrower: src/v3/compiler/src/emit/rust_target.rs:4738 only recognizes body: ArrowBody::NoBody, and src/v3/spec/rust.dag:970-978 documents first-class surface function types as ArrowBody::NoBody with other unsupported type-name contexts failing closed. As written, a TypeConnective::Arrow with ArrowBody::UserDefined that reaches this storage renderer will be collapsed into Rc<dyn Fn…> instead of rejected. Restrict the storage match to body: ArrowBody::NoBody and return EmitError::UnsupportedBehavior for other arrow bodies.
  3. CODING.md — Compliant. The new type rendering path makes the policy explicit in the signature rather than hiding it in ambient state: src/v3/compiler/src/emit/rust_target.rs:4849 threads arrow_policy: ArrowRustEmitPolicy through recursive type rendering, and the error path remains typed through Result<String, EmitError>.
  4. TESTING.md — Compliant. The added boundary tests are behavior-driven regression receipts at the right surface: src/v3/compiler/tests/boundary/m1_3_emit_rust_test.rs:338 pins impl Fn + Clone for callable params, :359 pins Rc/Rc param-return unification, :376 pins struct-field storage plus clone-only derive, and :399 pins nested generic storage in Vec<Rc<dyn Fn…>>.
  5. LOCKED DESIGN DECISIONS — Compliant. The PR explicitly acknowledges that first-class callable Rust rendering is position-specific rather than a single TypeRealization row at src/v3/spec/rust.dag:970-978, and it names the Rust emitter policy as the current authority; that is an explicit divergence note rather than a silent change to the realization model.
  6. TRACKED vs UNTRACKED DEBT — Finding, NON-BLOCKING. src/v3/compiler/src/emit/rust_target.rs:4672 says Future: lift the two spellings into rust.dag TypeRealization-style rows, and :4673 says the current state is “today: policy + spec comment.” That documents the temporary shape and bounds it to the two callable spellings, but it does not give a named, checkable dissolution trigger in the usual debt style. Add a concrete trigger such as “when position-specific Arrow carrier rows land in rust.dag, delete ArrowRustEmitPolicy and route both spellings through those rows,” or move this to a tracked debt note.

3. Verdict

REQUEST_CHANGES

The main mechanism is well targeted and well tested, but the storage renderer currently accepts all TypeConnective::Arrow bodies where the declared first-class callable contract only covers ArrowBody::NoBody. That should fail closed before merge; the debt-trigger comment can be cleaned up alongside it.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: codex / gpt-5.4
  • Commit: 1da46620 · Trigger: schedule
  • Thinking: 469s wall

APPROVE

Diff is narrowly scoped to Rust callable-type emission and the accompanying spec/schema updates. I didn’t find a concrete violation of INVARIANTS.md, docs/modeling-discipline.md, CODING.md, or TESTING.md in the changed lines, and the added boundary regressions cover the new impl Fn + Clone vs Rc<dyn Fn> / _no_debug behavior. Targeted tests I ran passed: emit_generic_bounds_survive, emit_callable_fn_identity_unifies_param_and_return_carriers, emit_callable_field_types_use_rc_dyn_fn_storage, and emit_callable_list_element_types_use_rc_dyn_fn_in_vec.

briansrls added a commit that referenced this pull request Apr 24, 2026
Codex #676 (P2/E-6): struct_def_no_debug / enum_def_no_debug on shared
TypeDefinitionSyntax forced Go/Python to carry unread duplicate fields.

- Revert TypeDefinitionSyntax to the five shared fields; add
  RustRecordDeriveTemplates in emit_model and data rust_record_derive_templates
  in rust.dag; emit_rust loads it by name alongside rust_language.
- Strip _no_debug keys from go.dag and python.dag; regen bootstrap; refresh
  parse_corpus_manifest.

Made-with: Cursor
briansrls added a commit that referenced this pull request Apr 24, 2026
Opus #676 exploratory: rust_borrowed_type_name_for_port only has one caller
(rust_type_name_for_user_function_parameter), which already handles Arrow
before borrow paths — drop the redundant peel+impl Fn branch and document the
contract so callable carrier choice stays in one place.

Made-with: Cursor

@briansrls briansrls left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review metadata

  • Provider / model: codex / unknown
  • Commit: 1da46620 · Trigger: schedule
  • Thinking: 377s wall

BLOCKING (1)

Root Cause

  • src/v3/compiler/src/emit/rust_target.rs Callable-storage derivability is inferred by an emitter-local walk that drops the template fact at TypeConnective::Instantiation → carry the no-Debug requirement through instantiated templates or declare it as a target fact before selecting struct/enum derive templates.

⚠️ One derive-selection hole remains for instantiated record templates carrying first-class function storage.

Comment thread src/v3/compiler/src/emit/rust_target.rs Outdated
TypeConnective::Disj { variants } => variants
.iter()
.any(|variant| self.decl_includes_first_class_arrow_data(variant.ty, visited)),
TypeConnective::Instantiation { arguments, .. } => arguments

This comment was marked as resolved.

briansrls added a commit that referenced this pull request Apr 24, 2026
Restrict StorageRcDynFn / RejectFirstClassFn handling to ArrowBody::NoBody
per first-class callable contract (P3). UserDefined arrows must not collapse
into Rc<dyn Fn…>.

Document a checkable dissolution trigger for ArrowRustEmitPolicy (api-review
non-blocking on #676).

Made-with: Cursor
briansrls added a commit that referenced this pull request Apr 24, 2026
Rebut PR #676 inline review: impl Fn + Clone is only for user-fn parameters;
render_struct_field always uses rust_type_name_for_decl_storage (Rc).

Move misplaced types-index prose to rust_type_name_for_port.

Made-with: Cursor
briansrls added a commit that referenced this pull request Apr 24, 2026
PR #676 inline: default struct_def still derives Debug, but records that
transitively carry first-class fn use rust_record_derive_templates
(clone-only). Comment ties render_type_declaration to C-8 and boundary test.

Made-with: Cursor
@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: codex / gpt-5.4
  • Commit: 52ae6e30 · Trigger: schedule
  • Thinking: 348s wall

Findings

  • BLOCKING — src/v3/compiler/src/emit/rust_target.rs:1418 loads the new no-Debug templates via declaration_by_name("rust_record_derive_templates"), even though the templates are authored as standalone spec data in src/v3/spec/rust.dag:1168 and are not carried on the active rust_language boundary (src/v3/spec/rust.dag:1210). That violates INVARIANTS.md P2 / docs/modeling-discipline.md practices 3, 5, and 6: the emitter is reading a Rust syntax fact through a convention-only global name bridge instead of through a declared target-spec edge, so this change introduces a new parallel authority exactly where the rest of the emit syntax work has been removing them.

Verdict
REQUEST_CHANGES. The carrier/derive fix itself looks narrowly scoped, but the new rust_record_derive_templates path regresses boundary discipline by adding an undeclared name lookup side channel. Wire this through a declared Rust syntax/config edge first, then the rest of the diff looks reasonable.

…bug twins

Opus #676: document P2 single-authority risk — Go/Python duplicate struct_def
into _no_debug for schema only; Rust differs; future lift to realization rows
or substrate derive fields (rust.dag direction). Refresh bootstrap + parse
corpus manifest after emit_model comment bytes change.

Made-with: Cursor
Codex #676 (P2/E-6): struct_def_no_debug / enum_def_no_debug on shared
TypeDefinitionSyntax forced Go/Python to carry unread duplicate fields.

- Revert TypeDefinitionSyntax to the five shared fields; add
  RustRecordDeriveTemplates in emit_model and data rust_record_derive_templates
  in rust.dag; emit_rust loads it by name alongside rust_language.
- Strip _no_debug keys from go.dag and python.dag; regen bootstrap; refresh
  parse_corpus_manifest.

Made-with: Cursor
Opus #676 exploratory: rust_borrowed_type_name_for_port only has one caller
(rust_type_name_for_user_function_parameter), which already handles Arrow
before borrow paths — drop the redundant peel+impl Fn branch and document the
contract so callable carrier choice stays in one place.

Made-with: Cursor
Restrict StorageRcDynFn / RejectFirstClassFn handling to ArrowBody::NoBody
per first-class callable contract (P3). UserDefined arrows must not collapse
into Rc<dyn Fn…>.

Document a checkable dissolution trigger for ArrowRustEmitPolicy (api-review
non-blocking on #676).

Made-with: Cursor
Rebut PR #676 inline review: impl Fn + Clone is only for user-fn parameters;
render_struct_field always uses rust_type_name_for_decl_storage (Rc).

Move misplaced types-index prose to rust_type_name_for_port.

Made-with: Cursor
PR #676 inline: default struct_def still derives Debug, but records that
transitively carry first-class fn use rust_record_derive_templates
(clone-only). Comment ties render_type_declaration to C-8 and boundary test.

Made-with: Cursor
Codex #676: callable detection for record #[derive(Debug)] omission must
see through zero-arity Instantiation heads, but the same walk is used for
user-fn return-type composition — following template there makes plain Int
pick up stdlib fn refs and wrongly forces Rc on callable params.

Split DeclFirstClassArrowWalk: AppliedTypeArguments (args only) vs
RecordDeriveOmitDebug (args + template).

Made-with: Cursor
Codex #676: remove declaration_by_name side channel for
rust_record_derive_templates; add LanguageSpec.record_derive_templates
in emit_model.dag and reference it from rust/go/python_language.

emit_rust parses templates from rust_language like other syntax bundles.
Go/Python carry stub RustRecordDeriveTemplates data for inhabitance.

Regenerate bootstrap and parse corpus manifest.

Made-with: Cursor
- Regenerate bootstrap + lens/variant/infer emit snapshots so PB-1 and
  SG lens drift tests match runtime regen_bootstrap/emit_rust_module.
- Do not use bare name for named first-class fn aliases in
  rust_type_name_for_decl_with_policy (storage path expands to
  Rc<dyn Fn…>; P3).
- Add boundary test for type F = fn-> field through alias; adjust
  lens structural_resolution assertions now that regen struct helpers
  omit Debug derive.

Made-with: Cursor
…676)

Track named dissolution: per-target derive lists in substrate replace
the shared-model Rust type and Go/Python stub rows; regen bootstrap
for span drift from comment lines.

Made-with: Cursor
- Document why return-type walk uses AppliedTypeArguments (Int substrate
  vs full template) and point to THESIS target-realization / rust.dag.
- Clarify RustRecordDeriveTemplates is the declared LanguageSpec hook, not
  an undeclared extension; P2 stubs remain in TODO.
- Regen bootstrap + parse manifest for emit_model line drift.

Made-with: Cursor
Return/derive walk now recurses non-List Instantiation templates with an
Int/Bool/String short-circuit; re-run regen_lens so checked-in #[derive]
lines match emit_rust_module.

Made-with: Cursor
Clarify at render site that return detection uses the same\ndecl_includes_first_class_arrow_data as storage/derive paths,\nnot a separate args-only pass — addresses review concern about\ntemplate-hidden fn vs C-8 Rc param alignment.

Made-with: Cursor
The PartialFunction (Map<K,V> template) record embeds algebra\nArrow+NoBody operation fields, not first-class user fn data.\nRecursing that template in decl_includes_first_class_arrow_data\nfalse-positived and over-forced Rc for callable params (C-8, PR #676).\n\n- Cache partial_function template on Dag like list_template\n- Skip that template in Instantiation branch + unit test

Made-with: Cursor
rust_type_name_for_decl_with_policy returned the alias name when\npeel did not reach a top-level Arrow. Named L = List<fn…> never\npeels that way but still carries first-class fn; skip the early\nreturn when decl_includes_first_class_arrow_data (C-8, PR #676).\n\nAdd emit_callable_field_types_expand_list_fn_named_alias.

Made-with: Cursor
Claude review on #676: gating the named-alias path on\ndecl_includes_first_class_arrow_data can leave named records that\nhold fn (via alias) with no Conj/Disj match arm, hitting\nMissingTypeRealization. Emitted user struct/enum names are valid\nRust in param slots; return the name. Test:\nemit_callable_struct_with_fn_field_names_ok_in_param_slot.

Made-with: Cursor
Named user records/sums with callable fields (Callback) must return\nthe declared Rust name for nested field types (Wrapper.cb) before\nthe List<fn>/alias decl_includes check — P2 name authority (codex #676).\n\nTest: emit_callable_nested_named_record_field_uses_type_name.

Made-with: Cursor
Reconcile with main (e.g. r1 surface manager #739); regen only.

Made-with: Cursor

@briansrls briansrls left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review metadata

  • Provider / model: codex / unknown
  • Commit: 5427838c · Trigger: schedule
  • Thinking: 262s wall

✅ The PR-scoped mixed code/spec diff fixes the prior callable carrier gaps and I found no new blocking concerns.

@briansrls

Copy link
Copy Markdown
Contributor Author

Review metadata

  • Provider / model: codex / codex-default
  • Commit: 8d5f61cc · Trigger: schedule
  • Thinking: 196s wall

Verdict: APPROVE

The diff looks clean against the pinned invariants: first-class Rust fn carrier handling is fail-closed, position-specific policy is documented and bounded, and the shared RecordDeriveTemplateBundle bridge has a named dissolution trigger. No concrete violations observed.

Verified with:
cargo test -p v3-compiler --test integration emit_callable -- --nocapture
cargo test -p v3-compiler --test integration emit_generic_bounds_survive -- --nocapture
cargo test -p v3-compiler emit::rust_target::tests::first_class_fn_walk_does_not_confuse_map_partial_function_with_user_fn -- --nocapture

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