Skip to content

FE-1518: Constraints as shared HIR expressions over parameters and state - #9371

Open
kube wants to merge 17 commits into
claude/web-optimizer-website-docsfrom
cf/fe-1518-optimization-constraints
Open

FE-1518: Constraints as shared HIR expressions over parameters and state#9371
kube wants to merge 17 commits into
claude/web-optimizer-website-docsfrom
cf/fe-1518-optimization-constraints

Conversation

@kube

@kube kube commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Important

Constraints are recorded and evaluable. Nothing enforces them yet.

Summary

Before this PR, a study could say which parameters to search but not which combinations are feasible, and nothing could state which simulation states are safe. Petrinaut already had one expression representation for user code, HIR, and the Python binding read the protocol's HIR as untyped JSON.

Constraints become a concept of their own: a boolean condition over the parameter space or over the simulation state, authored as TypeScript, lowered to HIR, and read the same way by the editors, the CLI, and the Python binding. Optimization studies are the first carrier of a constraint list. Python reads each one as a callable with a boolean, a signed margin, a pydantic validator, and a SymPy relation.

Links

Changes

Core

  • Constraint is one of two shapes, discriminated by space

    parameters is one expression over scenario.* and parameters.*, lowered on the scenario-expression surface.
    state is a metric-shaped body over the simulation state, lowered on the metric surface.

  • Each shape pins its own HIR surface, so a misfiled condition fails to parse

    constraintListSchema also rejects an id repeated across the two spaces.

  • HIR grammar gains a runtime schema

    hir/hir-schema.ts validates every node of a serialized function.
    Compile-time assertions compare schema output against the declared types by assignability and by key set, so a new field or node kind cannot land on one side alone.

  • Optimization manifest carries constraints: Constraint[]

    Shape and lowering live in constraint/, which knows nothing about studies.
    optimization.describe returns the list verbatim for a protocol client to evaluate.

  • Protocol schema carries the whole AST

    Every node kind is a named definition, so other languages generate a typed tree instead of an opaque blob.
    Constraint and HirExpr carry OpenAPI discriminators, so a generator emits a tagged union.

  • lowerConstraint returns the constraint rather than its HIR alone

    Language-worker method sdcpn/lowerConstraint takes a source and a context.
    Client method is requestConstraint.

  • Metric surface accepts an expected type

    buildMetricContext checks a body returns real or boolean, which type-checks a state condition as boolean.

Editor

  • Constraints section in the create-optimization drawer authors both kinds

    Parameter constraints edit as one-line expressions, state constraints as small code bodies.
    A condition that fails to compile or does not produce a boolean blocks Run with its first diagnostic.

Python binding

  • Generated models cover the HIR grammar as a tagged union

    A malformed document fails pydantic validation before evaluation starts.
    The evaluator walks typed nodes and passes basedpyright in strict mode.

  • ParameterConstraint and StateConstraint take the binding their space needs

    ParameterConstraint(scenario, parameters) answers before a run starts.
    StateConstraint(state, parameters) reads a state record keyed by place name.

  • Four readings of one condition

    Call it for the boolean, margin for the signed distance to the boundary, violation for the sign Optuna's constraints_func expects, check to raise ConstraintViolation.
    validator() packages the check for pydantic's AfterValidator, and violations() returns one entry per constraint for a sampler.

  • Parameter constraints have a symbolic reading

    to_sympy() returns a relation over one real symbol per parameter, ready for solve_univariate_inequality or simplify.
    It needs the sympy extra, and arrays, records, strings and Math.random raise NotSymbolicError.

Review fixes

  • Margins keep their sign at every boundary

    NaN slack resolves at the comparison leaf with the boolean's own answer.
    Strict comparisons and != go negative at the boundary instead of a satisfied-looking zero.
    && and || walk only the arms evaluation walks.

  • Evaluator arithmetic matches ECMAScript

    JS-faithful ** and Math.pow, the log family at zero and below, exp/cosh/sinh overflow, non-finite pass-through for the integral functions.
    String methods and .length on strings evaluate.

  • Run-time shape errors raise HirEvaluationError

    A Math call with the wrong arity, a non-integer index, range without arguments or with an unbounded span, a state that is not a record.
    Numbers coerce like JS Numbers, Math.max() and Math.min() with no arguments give their ECMAScript identities, and an infinite dividend's remainder is NaN.

  • Symbolic view keeps conditions and numbers apart

    Condition-valued ternaries become ITE, and a relation over a Piecewise becomes a chain of them, so SymPy's rewrite in condition positions never runs.
    == and != over conditions become Equivalent and Xor.
    % is the truncated remainder and Math.cbrt the real root, matching the evaluator.

Known issues

  • Deeply nested HIR fails validation

    Recursion guard in pydantic-core rejects a well-formed document past 255 levels, which a 256-term + chain reaches.

  • Numeric literal that overflows a double fails the schema

    1e400 lowers to Infinity, which the wire format cannot carry. Lowering should refuse it or emit the Infinity constant.

  • parse_constraint validates in lax mode

    Numeric strings coerce, where the protocol path the session uses is strict.

  • Constraint editors carry no accessible name

    Each added Monaco editor needs the list label and its index as an aria label.

Next steps

  • Placement beyond a study

    Model-level and scenario-level constraints are the RFC's call. This PR owns the shape, not the placement.

  • Enforcement

    margin is the signal a constrained sampler consumes. Nothing in the optimizer service calls it yet.

Test coverage

  • hir-schema.test.ts:

    Schema accepts real lowerings node for node and rejects unknown kinds, missing fields, and fields outside the grammar.

  • constraint.test.ts, lower.test.ts:

    Both shapes, surface pinning, ids unique across spaces, and lowering per space.

  • create-optimization-drawer.test.tsx:

    Authoring, a failing condition blocking submission, and the constraints the manifest carries.

  • test_hir.py:

    Evaluator semantics and margins against fixtures lowered by the TypeScript frontend, plus every run-time error path.

  • test_constraint.py:

    Parsing with full validation, both binding signatures, the four readings, the pydantic validator, and SymPy substitution agreeing with evaluation.

How to test

  • Open Petrinaut preview on Vercel
  • Load example > Supply Chain Profit
  • Simulate > Optimizations > Create
  • Select metric Profit, direction Maximize
  • Tick Optimize production_rate
  • Constraints > Add parameter constraint
  • Type scenario.reorder_threshold < scenario.batch_size
  • Constraints > Add state constraint
  • Type return state.places.LostSales.count <= 10;
  • Run
  • Expect the study to start
  • Reopen Create, add parameter constraint scenario.missing > 0, Run
  • Expect submission blocked, naming the unknown scenario parameter
  • Run cd libs/@local/petrinaut-python && uv run pytest && uv run basedpyright
  • Check both pass

Demo

Screenshots pending: the drawer's Constraints section with one parameter constraint and one state constraint, and the blocked-submission error.

@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hash Ready Ready Preview Sep 4, 2026 12:38pm UTC
petrinaut Ready Ready Preview Sep 4, 2026 12:38pm UTC
petrinaut-docs Ready Ready Preview Sep 4, 2026 12:38pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
hashdotdesign-tokens Ignored Ignored Preview Sep 4, 2026 12:38pm UTC

Request Review

@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team type/eng > backend Owned by the @backend team area/tests New or updated tests labels Aug 27, 2026
@kube
kube force-pushed the cf/fe-1518-optimization-constraints branch from 540f999 to ca852c2 Compare August 27, 2026 20:09
@kube
kube force-pushed the cf/fe-1518-optimization-constraints branch from ca852c2 to 08758e2 Compare August 27, 2026 23:53
@kube
kube force-pushed the cf/fe-1518-optimization-constraints branch from 08758e2 to a6118e1 Compare August 28, 2026 00:20
@kube
kube force-pushed the cf/fe-1518-optimization-constraints branch from a6118e1 to 5b4bf6c Compare August 28, 2026 01:35
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py Outdated
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py Outdated
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py Outdated
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py Outdated
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py Outdated
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py Outdated
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py Outdated
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py Outdated
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds shared optimization constraints across the Petrinaut UI, TypeScript HIR pipeline, CLI protocol, and Python client.

Changes:

  • Adds parameter/state constraint authoring and manifest persistence.
  • Adds constraint lowering, boolean type-checking, and worker APIs.
  • Adds Python HIR evaluation, signed margins, schemas, documentation, and tests.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
.changeset/optimization-constraints.md Records publishable package changes.
libs/@hashintel/petrinaut/docs/optimization.md Documents constraint authoring and behavior.
libs/@hashintel/petrinaut/src/react/lsp/context.ts Adds constraint lowering to LSP context.
libs/@hashintel/petrinaut/src/react/lsp/provider.tsx Exposes the new client method.
libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/create-metric-drawer.test.tsx Updates the language-client test mock.
libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.test.tsx Tests constraint manifest emission.
libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/optimizations/create-optimization-drawer.tsx Adds constraint editors and submission lowering.
libs/@hashintel/petrinaut-cli/schemas/optimization-protocol.schema.json Regenerates the protocol schema.
libs/@hashintel/petrinaut-cli/scripts/generate-protocol-schemas.ts Generates shared constraint definitions.
libs/@hashintel/petrinaut-cli/src/runtime/optimization.ts Passes constraints through describe().
libs/@hashintel/petrinaut-core/src/hir.ts Exports constraint lowering APIs.
libs/@hashintel/petrinaut-core/src/hir/constraint.test.ts Tests lowering and schema validation.
libs/@hashintel/petrinaut-core/src/hir/constraint.ts Implements constraint lowering.
libs/@hashintel/petrinaut-core/src/hir/surface-context.ts Adds boolean metric expectations.
libs/@hashintel/petrinaut-core/src/hir/typecheck.ts Type-checks boolean metric results.
libs/@hashintel/petrinaut-core/src/index.ts Exposes public constraint types and schemas.
libs/@hashintel/petrinaut-core/src/lsp/language-client.ts Adds the worker request API.
libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts Handles constraint lowering requests.
libs/@hashintel/petrinaut-core/src/lsp/worker/protocol.ts Defines the request protocol.
libs/@hashintel/petrinaut-core/src/optimization.ts Extends optimization schemas and types.
libs/@local/petrinaut-python/src/petrinaut/__init__.py Exports Python constraint APIs.
libs/@local/petrinaut-python/src/petrinaut/hir.py Implements HIR evaluation and margins.
libs/@local/petrinaut-python/src/petrinaut/models.py Regenerates Pydantic protocol models.
libs/@local/petrinaut-python/tests/hir_fixtures.json Adds TypeScript-lowered HIR fixtures.
libs/@local/petrinaut-python/tests/test_hir.py Tests evaluator and margin semantics.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +73 to +79
const checked = typecheckHir(lowered.fn, surfaceContext);
const errors = checked.diagnostics.filter(
(diagnostic) => diagnostic.severity === "error",
);
if (errors.length > 0) {
return { ok: false, diagnostics: errors };
}
Comment on lines +851 to +852
if (context.expected === "boolean") {
if (!isBoolish(returnType)) {
Comment on lines +167 to +170
code: z.string().trim().min(1).meta({
description:
"The authored TypeScript source — the editable text of record. `hir` is its lowered form; regenerating `hir` from `code` must be a no-op.",
}),
Comment on lines +156 to +159
"max": max,
"min": min,
"pow": _js_pow,
"round": _js_round,
Comment on lines +187 to +192
def _strict_equal(left: Any, right: Any) -> bool:
"""ECMAScript strict equality on the value kinds HIR produces: booleans
never equal numbers (`1 === true` is false in JS, unlike Python)."""
if isinstance(left, bool) != isinstance(right, bool):
return False
return left == right # type: ignore[no-any-return]
Comment on lines +273 to +277
if kind == "length":
target = self.eval(node["target"])
if not isinstance(target, (list, str)):
raise HirEvaluationError(".length target is not an array or string")
return len(target)
Comment on lines +365 to +376
if op == "/":
if right == 0:
# ECMAScript division never raises.
if left == 0:
return math.nan
return math.copysign(math.inf, left) * math.copysign(1, right)
return left / right
if op == "%":
if right == 0:
return math.nan
# ECMAScript remainder takes the dividend's sign (math.fmod).
return math.fmod(left, right)
Comment on lines +485 to +500
if op == "==":
left, right = self.eval(node["left"]), self.eval(node["right"])
if isinstance(left, bool) or isinstance(right, bool):
return math.inf if _strict_equal(left, right) else -math.inf
distance = abs(float(left) - float(right))
if math.isnan(distance):
return math.inf if _strict_equal(left, right) else -math.inf
return -distance
if op == "!=":
left, right = self.eval(node["left"]), self.eval(node["right"])
if isinstance(left, bool) or isinstance(right, bool):
return math.inf if not _strict_equal(left, right) else -math.inf
distance = abs(float(left) - float(right))
if math.isnan(distance):
return math.inf if not _strict_equal(left, right) else -math.inf
return _strict_slack(distance)
Comment on lines +501 to +502
if kind == "unary" and node["op"] == "!":
return -self.margin(node["operand"])
Comment on lines +252 to +256
<CodeEditor
language="typescript"
singleLine={!multiline}
value={draft.code}
height={multiline ? "96px" : undefined}
Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (claude/web-optimizer-website-docs@f146687). Learn more about missing BASE report.

Additional details and impacted files
@@                         Coverage Diff                          @@
##             claude/web-optimizer-website-docs    #9371   +/-   ##
====================================================================
  Coverage                                     ?   60.89%           
====================================================================
  Files                                        ?     1461           
  Lines                                        ?   146725           
  Branches                                     ?     6744           
====================================================================
  Hits                                         ?    89343           
  Misses                                       ?    56264           
  Partials                                     ?     1118           
Flag Coverage Δ
apps.hash-api 15.41% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented Sep 3, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 98 untouched benchmarks


Comparing cf/fe-1518-optimization-constraints (42f7102) with main (eea4e1f)1

Open in CodSpeed

Footnotes

  1. No successful run was found on claude/web-optimizer-website-docs (055a928) during the generation of this report, so main (eea4e1f) was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

never equal numbers (`1 === true` is false in JS, unlike Python)."""
if isinstance(left, bool) != isinstance(right, bool):
return False
return left == right

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Array equality uses value comparison

Medium Severity

_strict_equal uses Python ==, so two separately built arrays or records compare equal when their contents match. The TypeScript interpreter uses ===, which is referential, and the typechecker allows == on those shapes. A state constraint that compares token arrays can therefore hold in Python and fail in the frontend on the same values.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e63d35. Configure here.

Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py
code: z.string().trim().min(1).meta({
description:
"The authored TypeScript source, the editable text of record. `hir` is its lowered form; regenerating `hir` from `code` must be a no-op.",
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Schema trim shifts constraint spans

Low Severity

code is validated with z.string().trim(), which rewrites the stored source. Lowering records HIR spans against the untrimmed text, and the schema itself says regenerating hir from code must be a no-op. After petrinautOptimizationInputSchema.parse, spans no longer line up with code.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e63d35. Configure here.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 74b069d. Configure here.

Comment thread libs/@local/petrinaut-python/src/petrinaut/hir.py
kube added 17 commits September 4, 2026 14:25
…R schema

A constraint is one of two shapes discriminated by space: a parameter
constraint lowers on the scenario-expression surface, a state constraint on
the metric surface, and the shape itself pins the surface. The optimization
manifest carries a flat list of them instead of owning the type.

The HIR grammar gains a zod schema kept in lockstep with its types, so a
manifest validates every node and the CLI's protocol schema carries the full
AST for other languages to generate from.
…llables

The generated pydantic models now cover the whole HIR grammar as a
discriminated union, so the evaluator walks typed nodes under strict
pyright and a malformed document fails validation before anything runs.

ParameterConstraint and StateConstraint take the binding their space
needs and give the boolean, the signed margin, the Optuna-style violation,
a check that raises, and a pydantic validator; a parameter constraint over
plain arithmetic also translates to SymPy through the optional extra.
D2 reserves `constraint` and refuses it as an edge endpoint, so the
architecture diagram for the core layer failed to render with a
`core.constraint` layer inside it. The layer is `core.constraints`.
ruff formats the fenced Python in this package's Markdown, and the new
constraints section was written after the last local pass.
…w Boolean-aware

Well-formed HIR whose values do not fit at run time now raises
HirEvaluationError instead of a bare Python exception: a Math call with the
wrong arity, an index that is not an integer, range() with no arguments or
an unbounded span, a state that is not a record. Numbers coerce like JS
Numbers (an int past the double range reads as Infinity), Math.max() and
Math.min() with no arguments give their ECMAScript identities, and an
infinite dividend's remainder is NaN.

The SymPy view keeps conditions and numbers apart: a condition-valued
ternary is an ITE, a relation over a Piecewise folds into ITEs, and ==/!=
over conditions are Equivalent/Xor, so SymPy's lossy Piecewise rewrite in
condition positions never runs. % is the truncated remainder and Math.cbrt
the real cube root, matching the evaluator.
…ility

Mutual assignability lets an optional field go missing on either side of
the schema/type pair unnoticed; comparing the key sets too makes the
header's guarantee hold for optional fields.
A span that underflows to -Infinity is a backward range whose bounds
overflow a double; the TypeScript helper ceilings it to zero elements, and
the evaluator now does the same instead of reporting it as oversized.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) area/tests New or updated tests type/eng > backend Owned by the @backend team type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

2 participants