Skip to content

fix: bugs found analysing the advent-of-code-ndc solutions 🎄 - #342

Merged
timfennis merged 5 commits into
feat/type-checkerfrom
fix/literals-meeting-iterable
Nov 13, 2026
Merged

timfennis merged 5 commits into
feat/type-checkerfrom
fix/literals-meeting-iterable

Conversation

@timfennis

@timfennis timfennis commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Analysing every program in the advent-of-code-ndc repository turned up three things the type checker got wrong. Five programs that failed analysis now pass, including one master rejected too.

  • A literal passed for an Iterable<T> became an Iterable itself. Binding a literal's still-open type to an Iterable<T> parameter unified the two, so the value's type became Iterable<String>, which only a parameter may be. In let cur, path = ("A", ["A"]); path.len; path ++ [cur] the ++ then found no overload. Such a literal is now settled as the sequence it holds (a List<String> here), and its elements are bound to T.
  • An overloaded function used as a value in a generic function was decided once for the body. fn signs(xs) => xs.map(signum) reported that nothing says which signum is meant, though each call's types do. A function value now waits like a call: it's part of the function's scheme, each instance picks the overload its types call for, and the body compiled for Any arguments leaves the pick to the program. There the value is a new kind of function, Function::Overloads, made by a new MakeOverloads instruction from the overloads looked up where the value is made, most specific first; each call calls the first that accepts its arguments. Being an ordinary function, it can be passed to built-ins like map, captured, cached by a pure fn, and checked against a function type. Copying the scheme to a call now gives the value the call's types, and picking it counts as settling what it returns.
  • first and last only took lists. Their implementation takes any slice, and programs call them on pairs: .map(first) over (point, flag) tuples. They now also take tuples of one to twelve elements, typed by the element they return, and zip gives typed tuples for up to twelve sequences (it went up to four). A signature can't say "a tuple of any length", so each length gets its own overload; twelve is where Rust's standard library stops for tuples.

The manual's section on overloaded function values covers generic functions.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-13T18:32:53.202481Z 849bf4e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 096dab5ff0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +59 to +63
self.groups
.iter()
.flatten()
.map(|&symbol| Candidate::Scalar(symbol))
.collect(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Order deferred function-value overloads by specificity

When a generic function is stored as a value, it runs its shared body rather than a specialized instance, so this candidate ordering determines the overload actually called. Resolver groups are newest-first, but the VM's find_scalar_overload takes the first matching candidate; flattening them without ordering by specificity lets a newer Any fallback override an older exact overload. For example, fn choose(x: Int) => "int"; fn choose(x: Any) => "any"; fn apply(x) { let g = choose; g(x) } let h: Fn(Int) -> String = apply; print(h(1)); selects the Int overload during analysis but prints "any" at runtime. Order candidates within each scope by specificity, as ordinary dynamic call resolution does.

Useful? React with 👍 / 👎.

…ity 🎯

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@timfennis

Copy link
Copy Markdown
Owner Author

@codex review

…ments 📏

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9ed947139d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +136 to +138
Constraint::FunctionValue(value) if waiting.origin.is_none() => {
let binding = self.every_overload(value);
self.picks.bindings.insert(value.id(), binding);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Support deferred overload sets in native callback arguments

When a generic function is used as a value, its shared body now passes an Object::OverloadSet wherever an overloaded callback remains deferred, but native higher-order functions cannot consume that representation: NdcType::MutVmCallable in ndc_macros/src/vm_convert.rs accepts only Object::Function. For example, fn signs(xs) => xs.map(signum); let f: Fn(List<Int>) -> List<Int> = signs; print(f([1, -2])); passes analysis but fails at runtime instead of returning [1,-1]. Direct calls use specialized bodies, so the added signs test misses this path. Make deferred function values usable by native callbacks before storing them as dynamic bindings here.

Useful? React with 👍 / 👎.

Comment thread ndc_analyser/src/types.rs
Comment on lines +827 to +828
else {
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Defer iterable validation when a literal has no default yet

When the argument's literal variable holds another unsettled literal, literal_defaults returns None, so this branch accepts the argument without establishing any relationship to the iterable parameter. For example, in len([1][0]), the indexed element initially holds the unsettled numeric literal and is accepted here, even though it subsequently defaults to Int. iterations_waited_for in solver.rs explicitly excludes literal variables, so no deferred iteration constraint validates it later; debug builds instead reach the early-overload consistency assertion, while release builds leave the invalid call to fail at runtime. Keep the iterable requirement pending until the held literal's type is known rather than returning unconditional success.

Useful? React with 👍 / 👎.

… to the program 🔁

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@timfennis

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d53248764

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +136 to +138
Constraint::FunctionValue(value) if waiting.origin.is_none() => {
let binding = self.every_overload(value);
self.picks.bindings.insert(value.id(), binding);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Capture local overload candidates when creating function values

When a deferred function value contains locally declared overloads and escapes into another function, the emitted OverloadSet retains frame-relative variable locations rather than the functions themselves. For example, fn make(x) { fn choose(y: Int) => "int"; fn choose(y: Float) => "float"; let g = choose; fn() => g(x) } let m: Fn(Int) -> Fn() -> String = make; let h = m(1); print(h()); uses the shared generic body and captures this set in h. However, find_scalar_overload resolves its candidates relative to the currently executing closure's frame, not make's frame, so it reads unrelated slots or runs out of bounds. Materialize or capture the candidate functions when constructing an escaping overload value.

Useful? React with 👍 / 👎.

Comment thread ndc_vm/src/value/mod.rs Outdated
Object::Function(function) => return function.fit(target) == FunctionFit::Proven,
// The overloads of a function the analyser couldn't pick
// from, of which the program picks one for each call.
Object::OverloadSet { .. } => return true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate overload sets against the requested function type

When a deferred overload value flows through Any, this unconditional success allows it to satisfy incompatible function annotations without checking parameters or return values. For example, fn bad(x) { let g = abs; g(x); let a: Any = g; let b: Fn(Int) -> String = a; b(1) } let h: Fn(Int) -> String = bad; print(h(1)); runs the shared generic body, accepts the overload set as Fn(Int) -> String, and returns an integer instead. converted_to only creates checked wrappers for Object::Function, so overload sets bypass the existing return-type enforcement. Resolve a compatible overload or retain a checked function-type wrapper instead of accepting every function type.

Useful? React with 👍 / 👎.

Comment on lines +272 to +273
Binding::Dynamic(
value

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Support hashing deferred function values before memoized calls

When a shared generic body passes a deferred overload value to a pure fn, memoization hashes that value before executing the callback, but Object::hash still panics for every OverloadSet. For example, pure fn invoke(g, x) => g(x); fn apply(x) => invoke(abs, x); let h: Fn(Int) -> Int = apply; print(h(1)); now passes analysis but reaches the Rust panic OverloadSet cannot be used as a map key in Vm::dispatch_call, rather than producing 1. Direct specialized calls avoid this representation, so they do not cover the failure. Give deferred function values a supported hash/identity representation, or resolve them before the memoization cache hashes arguments.

Useful? React with 👍 / 👎.

…rdinary function 🧩

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@timfennis

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 849bf4e177

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ndc_vm/src/vm.rs
Comment on lines +768 to +772
.find(|candidate| candidate.matches_value_args(arguments))
.or_else(|| {
candidates
.iter()
.find(|candidate| candidate.conforms_value_args(arguments))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve overload priority when checking typed containers

When a deferred function value has a typed-container overload and an Any fallback, the first pass selects the fallback before the more-specific candidate receives its full conformance check. Value::matches_param deliberately rejects typed containers such as List<Int>, so even correctly ordered candidates produce the wrong result. For example, fn choose(x: List<Int>) => "ints"; fn choose(x: Any) => "any"; fn apply(x) { let g = choose; g(x) } let h: Fn(List<Int>) -> String = apply; print(h([1])); selects the list overload during analysis but prints "any" through the shared body. Perform the necessary container check before advancing to a lower-priority candidate rather than only after every fast-path candidate fails.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This matches how every call the program picks for works, not just function values: the cheap first pass looks only at what kind each value is, and an overload whose parameter needs a scan of a container is tried only when nothing takes the arguments without one. An ordinary call in the same shared body picks the same way: with fn direct(x) => choose(x); let k: Fn(List<Int>) -> String = direct; print(k([1])); it also prints "any". The manual documents this (troubleshooting, "Element types cost a scan"). Changing the order would make function values pick differently from calls, and changing it for calls too would put a container scan on every call the program picks for, so it is left as it is here.

@timfennis
timfennis merged commit 53d79c4 into feat/type-checker Nov 13, 2026
1 check passed
@timfennis
timfennis deleted the fix/literals-meeting-iterable branch November 13, 2026 18:33
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