fix(ltl): serialize the resolved deadline of a bounded window

Two obligations spawned at different steps from one duration-bounded formula
differ only in the deadline the evaluator resolved for them, so they serialized
identically and the trace erased a distinction the evaluator makes. The authored
window stays in amount/unit; the resolved deadline rides alongside.

Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J
This commit is contained in:
pj committed 2026-08-12 16:47:05 +05:30
1 parent c59b1337b3
commit 5dc137d11c
2 files changed
+69 -17

No files matched your search

+40 -17
View File
@@ -219,10 +219,43 @@ func (n NotFormula) describe() string { return "Not(" + n.Inner.describe() + ")"
func Describe(formula Formula) string { return formula.describe() } func Describe(formula Formula) string { return formula.describe() }
// withinNode mirrors the optional `within` clause attached to bounded // withinNode mirrors the optional `within` clause attached to bounded
// Eventually nodes in the JSON AST. // Always/Eventually nodes in the JSON AST.
type withinNode struct { type withinNode struct {
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
Unit string `json:"unit"` Unit string `json:"unit"`
// Deadline is the absolute instant the window closes, in unix
// milliseconds, present once the evaluator resolved a relative duration
// against an observation. Two obligations spawned at different steps from
// the same duration differ only here, so without it they serialize
// identically and the trace erases the distinction the evaluator makes.
Deadline int64 `json:"deadline,omitempty"`
}
// withinFor renders the bound clause of a bounded Always or Eventually. The
// authored window (steps or duration) stays in amount/unit so readers keep
// seeing what the spec asked for; the resolved deadline rides alongside.
func withinFor(
hasStepBound bool,
stepBound int,
duration time.Duration,
hasDeadline bool,
deadline time.Time,
) *withinNode {
var node *withinNode
switch {
case hasStepBound:
node = &withinNode{Amount: int64(stepBound), Unit: "steps"}
case duration > 0:
node = &withinNode{Amount: duration.Milliseconds(), Unit: "milliseconds"}
case hasDeadline:
return &withinNode{Amount: deadline.UnixMilli(), Unit: "deadline"}
default:
return nil
}
if hasDeadline {
node.Deadline = deadline.UnixMilli()
}
return node
} }
func (a AlwaysFormula) MarshalJSON() ([]byte, error) { func (a AlwaysFormula) MarshalJSON() ([]byte, error) {
@@ -231,14 +264,9 @@ func (a AlwaysFormula) MarshalJSON() ([]byte, error) {
Arg Formula `json:"arg"` Arg Formula `json:"arg"`
Within *withinNode `json:"within,omitempty"` Within *withinNode `json:"within,omitempty"`
}{Op: "always", Arg: a.Inner} }{Op: "always", Arg: a.Inner}
switch { payload.Within = withinFor(
case a.HasStepBound: a.HasStepBound, a.StepBound, a.Duration, a.HasDeadline, a.Deadline,
payload.Within = &withinNode{Amount: int64(a.StepBound), Unit: "steps"} )
case a.Duration > 0:
payload.Within = &withinNode{Amount: a.Duration.Milliseconds(), Unit: "milliseconds"}
case a.HasDeadline:
payload.Within = &withinNode{Amount: a.Deadline.UnixMilli(), Unit: "deadline"}
}
return json.Marshal(payload) return json.Marshal(payload)
} }
@@ -269,14 +297,9 @@ func (e EventuallyFormula) MarshalJSON() ([]byte, error) {
Arg Formula `json:"arg"` Arg Formula `json:"arg"`
Within *withinNode `json:"within,omitempty"` Within *withinNode `json:"within,omitempty"`
}{Op: "eventually", Arg: e.Inner} }{Op: "eventually", Arg: e.Inner}
switch { payload.Within = withinFor(
case e.HasStepBound: e.HasStepBound, e.StepBound, e.Duration, e.HasDeadline, e.Deadline,
payload.Within = &withinNode{Amount: int64(e.StepBound), Unit: "steps"} )
case e.Duration > 0:
payload.Within = &withinNode{Amount: e.Duration.Milliseconds(), Unit: "milliseconds"}
case e.HasDeadline:
payload.Within = &withinNode{Amount: e.Deadline.UnixMilli(), Unit: "deadline"}
}
return json.Marshal(payload) return json.Marshal(payload)
} }
+29
View File
@@ -288,3 +288,32 @@ func TestResidual_HoldsViolatedPending(t *testing.T) {
t.Errorf("pending residual:\n got: %s\nwant: %s", body, want) t.Errorf("pending residual:\n got: %s\nwant: %s", body, want)
} }
} }
// TestMarshalJSON_ResolvedDeadlineDistinguishesObligations: obligations spawned
// at different steps from one duration-bounded eventually differ only in the
// deadline the evaluator resolved for them, and collapse keeps them apart on
// exactly that. The serialized AST has to keep them apart too, or the trace
// shows N copies of one node where the evaluator has N different obligations.
func TestMarshalJSON_ResolvedDeadlineDistinguishesObligations(t *testing.T) {
base := time.UnixMilli(1700000000000)
first := EventuallyFormula{
Inner: PureFormula{Value: false},
Duration: 300 * time.Second,
Deadline: base.Add(300 * time.Second),
HasDeadline: true,
}
second := first
second.Deadline = base.Add(301 * time.Second)
firstBody, _ := json.Marshal(first)
secondBody, _ := json.Marshal(second)
if string(firstBody) == string(secondBody) {
t.Errorf("obligations with different deadlines serialize identically: %s", firstBody)
}
if !strings.Contains(string(firstBody), `"unit":"milliseconds"`) {
t.Errorf("authored window lost: %s", firstBody)
}
if !strings.Contains(string(firstBody), `"deadline":1700000300000`) {
t.Errorf("resolved deadline missing: %s", firstBody)
}
}