Skip to content

[Dev] Code improvement and bug fixes #197

@jbcaillau

Description

@jbcaillau

Likely errors / bugs (with suggested corrections)

1) Typo/bug: control_fun used without :

In multiple @match branches the code uses control_fun (a bare identifier) instead of the symbol :control_fun.

Example (from your snippet around constraint codegen):

:state_fun || control_fun || :mixed => begin

This will either:

  • throw UndefVarError: control_fun not defined at macro-expansion/runtime (depending on how @match expands), or
  • silently match something unintended (worst case).

Fix
Replace control_fun with :control_fun everywhere it appears in match patterns.

Search and fix in src/onepass.jl:

  • :state_fun || control_fun || :mixed
  • :state_fun || control_fun || :mixed in p_constraint_fun! too (I saw a similar pattern earlier: :state_fun || control_fun || :mixed => ...)

Permalink for one occurrence:

CTParser.jl/src/onepass.jl

Lines 828 to 843 in 5aad902

:state_fun || control_fun || :mixed => begin
code = :(length($e1) == length($e3) == 1 || throw("this constraint must be scalar")) # (vs. __throw) since raised at runtime
xt = __symgen(:xt)
ut = __symgen(:ut)
e2 = replace_call(e2, [p.x, p.u], p.t, [xt, ut])
j = __symgen(:j)
e2 = subs2(e2, xt, p.x, j)
e2 = subs2(e2, ut, p.u, j)
e2 = subs(e2, p.t, :($(p.t0) + $j * $(p.dt)))
concat(
code,
:($pref.constraint(
$p_ocp, $e2 for $j in 0:grid_size; lcon=($e1), ucon=($e3)
)),
)
end

I used code search and results may be incomplete (search UI: https://github.com/control-toolbox/CTParser.jl/search?q=control_fun&type=code).


2) Possible semantic bug: as_range returns a Vector, but later code assumes a range-like object

as_range(x) = is_range(x) ? x : [x]

Then in several places you do:

length($e1) == length($e3) == length($rg) || throw("wrong bound dimension")

If rg is a scalar index (like 3), as_range(rg) returns [3], which is OK for length, but later some expressions expect rg to be valid in slicing contexts A[$rg] or in comprehensions for i in $rg.

That works for a Vector{Int} but:

  • it changes iteration order/type vs 3:3
  • it may allocate and inhibit inference in generated code (minor)
  • more importantly, in other places you may rely on rg being an Expr representing a range (not a realized array) if you’re building quoted code.

Safer fix (design + correctness)
Use an expression-level normalization:

  • if it’s not a range, turn it into :(($x):($x)) (a 1-length UnitRange) instead of [x].
    • At AST level: Expr(:call, :(:), x, x).

So something like:

as_range_expr(x) = is_range(x) ? x : :($x:$x)

and use that consistently in code generation paths (especially :exa), where rg is interpolated into quoted expressions.


3) Potential scoping hazard: __wrap introduces local ex, prints, then throw(ex)

__wrap is:

quote
    local ex
    try
        $e
    catch ex
        println("Line ", $n, ": ", $line)
        throw(ex)
    end
end

This is mostly fine, but:

  • it prints to stdout unconditionally (might be undesirable in libraries; better to use @error or rethrow with enriched exception)
  • it loses stack context if later code catches/rethrows (it preserves original exception though)

Improvement
Prefer rethrow() inside catch to preserve backtrace:

catch
    println(...)
    rethrow()
end

If you need the exception object, do:

catch ex
    println(...)
    rethrow(ex)
end

In Julia, rethrow() preserves the original backtrace best.


4) Alias substitution loop may be order/termination sensitive

In parse!:

for a in keys(p.aliases)
    e = subs(e, a, p.aliases[a])
end

Because aliases can be Expr keys and values can introduce other aliases, a single pass may yield partially-expanded expressions, and alias ordering can change semantics.

Fix / improvement

  • Either document “single-pass aliasing” explicitly, or
  • Apply substitution until fixpoint (with max-iteration guard), or
  • Separate “lexical sugar aliases” (like <=) from “user aliases” and apply in phases.

Performance / design / refactoring suggestions

A) Reduce duplication via a backend “emitter” object (strategy pattern)

You currently have a pattern:

  • p_variable! does generic validation + updates p
  • then calls parsing(:variable, backend)(...)
  • and separately you maintain p_variable_fun!, p_variable_exa!, etc.

That’s already a strategy pattern, but it’s implemented via global dispatch tables (presumably parsing elsewhere) + many parallel functions.

Refactor idea
Define small backend types:

abstract type Backend end
struct FunBackend <: Backend end
struct ExaBackend <: Backend end

Then implement:

emit_variable!(::FunBackend, p, p_ocp, ...)
emit_variable!(::ExaBackend, p, p_ocp, ...)

This improves:

  • discoverability (methods grouped by concept)
  • testability (backend behavior can be unit-tested with the same front-end parser calls)
  • avoids symbol-based dispatch and reduces runtime branching

B) Consolidate the huge objective parsing match table

The parse! objective section enumerates many algebraic permutations for Bolza/Lagrange with factors and signs, primarily to enforce “the prefactor must not depend on time”.

Refactor idea
Normalize objective expressions first:

  • parse the objective as an AST
  • extract (mayer_term, lagrange_integrand, sense)
  • rewrite e1 - ∫(e2) into (e1, -e2) etc.
  • check time-dependence in exactly one place

This will shrink parse! substantially and reduce bug surface.

C) Avoid repeated has(expr, p.t) scans by caching

You repeatedly compute autonomy checks like:

xut = __symgen(:xut)
ee = replace_call(e, [p.x, p.u], p.t, [xut, xut])
has(ee, p.t) && (p.is_autonomous = false)

has likely walks the whole AST each time.

Improvement

  • Have replace_call optionally return a flag “time present” while transforming, or
  • Maintain a lightweight contains_time::Bool returned by a single traversal.

D) Exa box constraints: accumulate data, not code

For :exa, you build code blocks p.box_x = concat(p.box_x, code_box) etc. That means you’re building large quoted expressions incrementally.

Improvement
Accumulate data in ParsingInfo (e.g., store a vector of (kind, rg, lb_expr, ub_expr, lineinfo)), then at the end generate code once. Benefits:

  • less AST concatenation overhead
  • easier to validate consistency (e.g., conflicting bounds)
  • enables better error messages (“bound set twice”)

E) Consistent “range normalization” across fun/exa

Right now, range/index handling differs by backend (and some comments mention :fun allows i to be a range for dynamics, but :exa doesn’t). Consider defining explicit policy per construct:

  • dynamics coord index: must be integer for all backends (or make a separate syntax for ranges)
  • constraints range: always accept scalar or range, normalize to UnitRange at AST level

This makes the DSL more predictable.


One concrete “must-fix” summary

  1. Replace every control_fun in @match patterns with :control_fun.
  2. Consider changing as_range to return an AST range (x:x) rather than allocating [x] when generating code (especially for :exa).
  3. Prefer rethrow() in __wrap to preserve backtraces.

If you want, paste the remainder of onepass.jl after line ~996 (where your snippet cut off), and I can check the rest for the same control_fun typo and any similar symbol-pattern issues.

Metadata

Metadata

Assignees

Labels

internal devModification to the code is requested and should not affect user experiment

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions