FE-1518: Constraints as shared HIR expressions over parameters and state - #9371
FE-1518: Constraints as shared HIR expressions over parameters and state#9371kube wants to merge 17 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
540f999 to
ca852c2
Compare
ca852c2 to
08758e2
Compare
08758e2 to
a6118e1
Compare
a6118e1 to
5b4bf6c
Compare
There was a problem hiding this comment.
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.
| const checked = typecheckHir(lowered.fn, surfaceContext); | ||
| const errors = checked.diagnostics.filter( | ||
| (diagnostic) => diagnostic.severity === "error", | ||
| ); | ||
| if (errors.length > 0) { | ||
| return { ok: false, diagnostics: errors }; | ||
| } |
| if (context.expected === "boolean") { | ||
| if (!isBoolish(returnType)) { |
| 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.", | ||
| }), |
| "max": max, | ||
| "min": min, | ||
| "pow": _js_pow, | ||
| "round": _js_round, |
| 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] |
| 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) |
| 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) |
| 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) |
| if kind == "unary" and node["op"] == "!": | ||
| return -self.margin(node["operand"]) |
| <CodeEditor | ||
| language="typescript" | ||
| singleLine={!multiline} | ||
| value={draft.code} | ||
| height={multiline ? "96px" : undefined} |
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| never equal numbers (`1 === true` is false in JS, unlike Python).""" | ||
| if isinstance(left, bool) != isinstance(right, bool): | ||
| return False | ||
| return left == right |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 8e63d35. Configure here.
| 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.", | ||
| }), |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 8e63d35. Configure here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
❌ 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.
…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.


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
spaceconstraints: Constraint[]lowerConstraintreturns the constraint rather than its HIR aloneEditor
Python binding
ParameterConstraintandStateConstrainttake the binding their space needsReview fixes
HirEvaluationErrorKnown issues
parse_constraintvalidates in lax modeNext steps
Test coverage
hir-schema.test.ts:constraint.test.ts,lower.test.ts:create-optimization-drawer.test.tsx:test_hir.py:test_constraint.py:How to test
Profit, direction Maximizescenario.reorder_threshold < scenario.batch_sizereturn state.places.LostSales.count <= 10;scenario.missing > 0, Runcd libs/@local/petrinaut-python && uv run pytest && uv run basedpyrightDemo
Screenshots pending: the drawer's Constraints section with one parameter constraint and one state constraint, and the blocked-submission error.