"""
    load_predicates(plant)

Parse `reachability/predicates.json` into a NamedTuple of concretized
halfspaces + constants.  Julia counterpart of `reachability/load_predicates.m`.

Returns a NamedTuple with fields:
- `constants`               — NamedTuple of plant-derived constants
                                (T_c0, T_f0, T_cold0, T_standby, etc.)
- `operational_deadbands`   — Dict{Symbol, NamedTuple{(:A_poly, :b_poly, :meaning)}}
- `safety_limits`           — same shape
- `mode_invariants`         — NamedTuple with .inv1_holds, .inv2_holds,
                                each holding conjoined A_poly, b_poly,
                                and a components list

Each `A_poly` is an `n_halfspaces × 10` matrix; the predicate is
`{x : A_poly * x <= b_poly}`.

Multi-coefficient rows are supported: an entry with `"row"` key listing
`[[state_index, coeff], ...]` pairs populates multiple columns of one
row.  Single-coefficient entries (`"state_index"` + `"coeff"`) still
work for backward compat.
"""
function load_predicates(plant)
    here = @__DIR__
    json_path = joinpath(here, "..", "..", "reachability", "predicates.json")
    J = JSON.parsefile(json_path)

    # Context for rhs_expr evaluation.
    T_c0     = plant.T_c0
    T_f0     = plant.T_f0
    T_cold0  = plant.T_cold0
    T_standby_offset_C = J["derived"]["T_standby_offset_C"]
    T_standby = T_c0 + T_standby_offset_C

    function eval_rhs(expr::AbstractString)
        # Tiny sandbox — only the names below are visible.
        local_ctx = Dict(
            :T_c0              => T_c0,
            :T_f0              => T_f0,
            :T_cold0           => T_cold0,
            :T_standby         => T_standby,
        )
        parsed = Meta.parse(expr)
        # Evaluate within a let-block binding our locals.
        eval_expr = quote
            let T_c0 = $T_c0, T_f0 = $T_f0, T_cold0 = $T_cold0, T_standby = $T_standby
                $parsed
            end
        end
        return eval(eval_expr)
    end

    function parse_group(group_dict)
        out = Dict{Symbol, NamedTuple}()
        for (name, entry) in group_dict
            startswith(name, "_") && continue
            !isa(entry, AbstractDict) && continue
            !haskey(entry, "halfspaces") && continue

            hs_list = entry["halfspaces"]
            n_hs = length(hs_list)
            A_poly = zeros(n_hs, 10)
            b_poly = zeros(n_hs)
            for (i, hs) in enumerate(hs_list)
                if haskey(hs, "row")
                    # Multi-coefficient row: [[idx, coeff], ...]
                    for pair in hs["row"]
                        idx   = Int(pair[1])
                        coeff = Float64(pair[2])
                        A_poly[i, idx] = coeff
                    end
                else
                    # Single-coefficient row.
                    A_poly[i, Int(hs["state_index"])] = Float64(hs["coeff"])
                end
                b_poly[i] = eval_rhs(hs["rhs_expr"])
            end
            meaning = get(entry, "meaning", "")
            out[Symbol(name)] = (A_poly=A_poly, b_poly=b_poly, meaning=meaning)
        end
        return out
    end

    deadbands = parse_group(J["operational_deadbands"])
    safety    = parse_group(J["safety_limits"])

    # Mode invariants: conjunctions of safety_limits entries.
    inv_dict = J["mode_invariants"]
    mode_inv = Dict{Symbol, NamedTuple}()
    for (name, entry) in inv_dict
        startswith(name, "_") && continue
        !isa(entry, AbstractDict) && continue
        !haskey(entry, "conjunction_of") && continue

        components = entry["conjunction_of"]
        isa(components, String) && (components = [components])
        A_all = Matrix{Float64}(undef, 0, 10)
        b_all = Vector{Float64}()
        for comp in components
            sym = Symbol(comp)
            if haskey(safety, sym)
                A_all = [A_all; safety[sym].A_poly]
                b_all = [b_all; safety[sym].b_poly]
            end
        end
        mode_inv[Symbol(name)] = (A_poly=A_all, b_poly=b_all,
                                  components=components,
                                  meaning=get(entry, "meaning", ""))
    end

    constants = (
        T_c0                       = T_c0,
        T_f0                       = T_f0,
        T_cold0                    = T_cold0,
        T_standby                  = T_standby,
        T_standby_offset_C         = T_standby_offset_C,
        T_standby_offset_F         = J["derived"]["T_standby_offset_F"],
        t_avg_in_range_halfwidth_C = J["derived"]["t_avg_in_range_halfwidth_C"],
        p_above_crit_threshold_n   = J["derived"]["p_above_crit_threshold_n"],
        T_fuel_limit_C             = J["derived"]["T_fuel_limit_C"],
        T_c_high_trip_C            = J["derived"]["T_c_high_trip_C"],
        n_high_trip                = J["derived"]["n_high_trip"],
    )

    return (
        constants             = constants,
        operational_deadbands = deadbands,
        safety_limits         = safety,
        mode_invariants       = mode_inv,
    )
end
