Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion semantica/explorer/routes/decisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def _node_to_decision(node: dict) -> DecisionResponse:
reasoning=properties.get("reasoning", ""),
outcome=properties.get("outcome", ""),
confidence=float(properties.get("confidence", 0.0) or 0.0),
timestamp=properties.get("timestamp"),
timestamp=None if properties.get("timestamp") is None else str(properties.get("timestamp")),
metadata=properties,
Comment on lines +26 to 27

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Inconsistent timestamp in metadata 🐞 Bug ≡ Correctness

_node_to_decision() now stringifies the top-level timestamp but still returns the raw node
properties as metadata, so responses can contain timestamp as str while metadata['timestamp']
remains a float. This creates inconsistent API output and can break consumers that read/compare the
timestamp from metadata instead of the top-level field.
Agent Prompt
### Issue description
`_node_to_decision()` coerces `DecisionResponse.timestamp` to `str`, but `metadata` is set to the original `properties` dict. When the stored value is a float (as produced by `ContextGraph.record_decision()`), the response contains two different timestamp types (`timestamp: str` vs `metadata.timestamp: float`).

### Issue Context
- This PR intentionally coerces the boundary to satisfy `DecisionResponse.timestamp: Optional[str]`.
- The endpoint also returns `metadata=properties`, which includes the same `timestamp` key.

### Fix Focus Areas
- semantica/explorer/routes/decisions.py[17-28]

### Suggested fix
1. Read the timestamp once into a local variable.
2. Build a new `metadata` dict (copy) and normalize or remove the `timestamp` key so it matches the top-level field.

Example:
```py
properties = node.get("properties", {})
ts_value = properties.get("timestamp")
ts = None if ts_value is None else str(ts_value)
metadata = dict(properties)
metadata["timestamp"] = ts  # or: metadata.pop("timestamp", None)

return DecisionResponse(
    ...,
    timestamp=ts,
    metadata=metadata,
)
```
Optionally add a small unit test for `_node_to_decision()` with `properties['timestamp']` as `float` to lock in the behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

)

Expand Down
92 changes: 92 additions & 0 deletions tests/explorer/test_decision_route_timestamp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Unit tests for the explorer decision route adapter.

Regression coverage for the ``/api/decisions`` 422 bug: a decision node
whose ``timestamp`` is stored as a POSIX float (the format
``ContextGraph.record_decision`` writes) must not break Pydantic
validation of ``DecisionResponse`` (typed ``Optional[str]``).
"""

import math

from semantica.explorer.routes.decisions import _node_to_decision
from semantica.explorer.schemas import DecisionResponse


def _decision_node(timestamp):
"""Build a minimal decision node dict shaped like ContextGraph.to_dict()."""
return {
"id": "d-1",
"type": "decision",
"properties": {
"category": "loan_underwriting",
"scenario": "A-7291 review",
"reasoning": "DTI within policy",
"outcome": "approved",
"confidence": 0.94,
"timestamp": timestamp,
},
}


def test_timestamp_float_is_coerced_to_str():
"""A POSIX-float timestamp (what record_decision stores) must validate.

Before the fix this raised a ValidationError (422 on the endpoint).
"""
node = _decision_node(timestamp=1786513069.694965)

decision = _node_to_decision(node)

assert isinstance(decision, DecisionResponse)
assert decision.timestamp == "1786513069.694965"
# round-trips through Pydantic strict str validation
assert decision.confidence == 0.94
assert decision.outcome == "approved"


def test_timestamp_int_is_coerced_to_str():
"""Integer timestamps (some stores serialize without sub-second precision)
are handled by the same coercion path."""
decision = _node_to_decision(_decision_node(timestamp=1786513069))

assert decision.timestamp == "1786513069"


def test_timestamp_none_is_preserved():
"""A missing timestamp must stay None, not become the string 'None'."""
decision = _node_to_decision(_decision_node(timestamp=None))

assert decision.timestamp is None


def test_timestamp_str_passes_through():
"""An already-string timestamp is left intact."""
decision = _node_to_decision(_decision_node(timestamp="2026-08-12T10:04:20"))

assert decision.timestamp == "2026-08-12T10:04:20"


def test_timestamp_missing_key_defaults_to_none():
"""A decision node without a timestamp key at all should not raise."""
node = {
"id": "d-2",
"type": "decision",
"properties": {"category": "x", "outcome": "y"},
}

decision = _node_to_decision(node)

assert decision.timestamp is None


def test_float_timestamp_not_nan_or_inf():
"""Sanity guard: degenerate float values still coerce to a finite string
rather than crashing validation."""
for value in (float("nan"), float("inf")):
node = _decision_node(timestamp=value)
decision = _node_to_decision(node)
assert isinstance(decision.timestamp, str)
if math.isnan(value):
assert "nan" in decision.timestamp.lower()
else:
assert "inf" in decision.timestamp.lower()