The evidence_diff skill
Ambang publishes one tool of its own back into the RYO ecosystem. It answers the question none of the six RYO tools can: what changed since I last looked. This page is its contract.
The gap it fills
Each of the six RYO tools returns a point-in-time read. Ask analyze_token twice and you get two answers with no relationship between them; there is no endpoint anywhere in the catalogue that takes a previous reading and tells you how the picture moved. An agent that wants to react to change has to keep its own record and do the comparison itself.
evidence_diff is that comparison, exposed as a tool. It takes two saved snapshot sets that Ambang already stored, resolves the same logical fields on both sides, and reports the differences, the fields it could not compare and why, and optionally what the change did to a named strategy's decision.
It answers in RYO's own envelope shape, with the same ten top-level keys the six RYO tools use, so a caller that already handles a RYO response handles this one with no new convention to learn.
apps/server/app/evidence_diff.py, apps/server/app/skills/README.md
Two surfaces, one envelope
It is reachable two ways, and both return the identical envelope for identical arguments:
- POST /mcp
- MCP JSON-RPC. Implements
initialize,tools/listandtools/call. Arguments go in theargumentsobject; the envelope comes back JSON-encoded in the text content of the result. - POST /tools/evidence_diff/call
- Plain REST. The JSON body is the argument object, and the response body is the envelope itself.
The parity is not an intention, it is a test. test_evidence_diff_contract.py::test_contract_holds_over_mcp_surface and ::test_contract_holds_over_rest_surface fail the build if the two ever diverge, and ::test_tools_list_advertises_the_real_schema fails it if the advertised input schema stops matching the function that implements it.
Arguments
Six optional-looking arguments, no required one, and no combination that produces an error. Every argument is a string, and any argument sent as something other than a string is dropped and reported in warnings rather than rejected.
| Argument | Type | Required | If omitted |
|---|---|---|---|
symbol | string | conditionally | Required unless both snapshot set ids are given. With neither, status becomes unavailable, because there is nothing to diff. |
since | string, ISO-8601 | no | from becomes the earliest snapshot set on record for the symbol. A since that matches nothing falls back the same way, with a warning. |
from_snapshot_set | string, snapshot set id | no | Falls back to since, then to the earliest snapshot set on record. |
to_snapshot_set | string, snapshot set id | no | Falls back to the latest snapshot set for the symbol. |
strategy_id | string, strategy id | no | data.impact is null and availability.impact is unavailable. An unknown id adds a warning and still returns null rather than failing. |
schema_version | string | no | Serves receipts-1.0, as it does for every value. Any other value adds a warning and changes nothing else. |
The schema declares additionalProperties: false, which documents that nothing else is part of the contract. The function is more forgiving than the schema: an unrecognised keyword is absorbed and reported as ignored unknown argument, so a caller written against a future version cannot crash this one.
What comes back
Every response, whatever its status, carries exactly ten top-level keys: schema_version, tool, status, data_mode, as_of, request, data, summary, availability, warnings.
- data.changes
- One entry per field that differs, as
{field, from, to, delta, pct, from_available, to_available}, ranked by the size of the percentage change. Fields with no meaningful percentage sort last rather than sorting as zero. - data.unavailable
- One entry per field that could not be compared, as
{field, side, reason}. A field that is missing on either side is never diffed; it is reported here instead, and its presence forcesstatustopartial. Nothing is zero-filled to keep the list tidy. - data.impact
- Present only when a
strategy_idwas given and found. Reports the side before and after, the nearest flip condition on each side, whether the decision changed and whether the flip got closer. - data_mode
- The worse of the two sides, ordered
live,mixed,simulated,unknown. A diff between one live snapshot and one simulated snapshot is reported as simulated, never as live.
A partial answer is a normal answer here. Most real responses from this build are partial, because RYO currently marks its derivatives section unavailable and so one field never resolves on either side. That is reported as a named field with a printed reason, not as a failure and not as a zero. See Coverage.
schema_version, and what it does
This build implements exactly one output contract, receipts-1.0, and returns it verbatim on every response regardless of anything in the request. The response shape never varies by version.
The schema_version argument exists so that a caller can say which contract it was written against. If it names a version this build does not implement, the call still succeeds, still returns receipts-1.0, and adds a warning naming both versions. It never raises and it never returns a different shape.
Asking for a version that does not exist:
curl -s -X POST http://127.0.0.1:8712/tools/evidence_diff/call \
-H 'content-type: application/json' \
-d '{"symbol":"SOL","schema_version":"receipts-2.0"}'The reply, abridged to the four keys that matter here. Captured from this build on 2026-09-03.
{
"schema_version": "receipts-1.0",
"status": "partial",
"warnings": [
"requested schema_version 'receipts-2.0' is not implemented; served 'receipts-1.0'",
"from and to are the same snapshot set; no changes"
],
"request": {
"symbol": "SOL",
"schema_version": "receipts-2.0"
}
}RYO's own envelopes are read with the same tolerance in the other direction. RYO fixtures carry "schema_version": "1.0", which is a different version space entirely, and it is never required to match. An unrecognised or missing upstream version does not block field resolution and does not force an unavailable status. Only genuinely missing data does that.
It warns, it does not raise
A tool that throws is a tool an agent has to defend against. This one is written so there is no argument combination that produces an exception or an HTTP 500. Every failure mode resolves to an ordinary envelope with a status of partial or unavailable and an explanatory line in warnings:
- An argument of the wrong JSON type is dropped before it can reach the database layer, and reported as
ignored 'symbol': expected a string, got list. - An unknown argument name is absorbed and reported as
ignored unknown argument. - A
sincethat matches no stored snapshot falls back to the earliest one and says so. - A
strategy_idthat does not exist addsstrategy <id> not foundand returnsdata.impact: null. - No snapshot set at all for the symbol returns
status: unavailablewith empty data, rather than an error.
Over the MCP surface, even an unexpected internal failure comes back as a normal JSON-RPC result with isError: true in the content, not as a JSON-RPC error object, so a caller parsing tool results never has to handle two shapes.
apps/server/app/evidence_diff.py, _validated_str(), **_unknown_args, apps/server/app/mcp.py, handle_jsonrpc()
A real call, and what it really returned
Below is an actual request against this build and the actual reply, captured on 2026-09-03 and pasted unedited. It is worth reading for what it admits rather than for what it reports.
The request:
curl -s -X POST http://127.0.0.1:8712/tools/evidence_diff/call \
-H 'content-type: application/json' \
-d '{"symbol":"SOL","strategy_id":"kaze"}'The reply, in full:
{
"schema_version": "receipts-1.0",
"tool": "evidence_diff",
"status": "partial",
"data_mode": "live",
"as_of": "2026-09-02T09:10:45Z",
"request": {
"symbol": "SOL",
"strategy_id": "kaze"
},
"data": {
"from": {
"snapshot_set_id": "8b0cc8ba4c0c13aac960025fd277c3b6ed5b52b4881175b99bfd25648410270d",
"as_of": "2026-09-02T09:10:45Z",
"source": "live"
},
"to": {
"snapshot_set_id": "8b0cc8ba4c0c13aac960025fd277c3b6ed5b52b4881175b99bfd25648410270d",
"as_of": "2026-09-02T09:10:45Z",
"source": "live"
},
"changes": [],
"unavailable": [
{
"field": "oi_change_24h",
"side": "from",
"reason": "deep_analysis.derivatives availability unavailable"
},
{
"field": "oi_change_24h",
"side": "to",
"reason": "deep_analysis.derivatives availability unavailable"
}
],
"impact": {
"strategy_id": "kaze",
"side_from": "NO_TRADE",
"side_to": "NO_TRADE",
"nearest_flip_from": {
"field": "rsi14",
"current": 63.6,
"threshold": 80.0,
"op": "<",
"distance": 16.400000001000002,
"distance_pct": 20.500000001250005,
"direction": "up",
"would_become": "SHORT",
"evaluable": true,
"reason": "single-field change"
},
"nearest_flip_to": {
"field": "rsi14",
"current": 63.6,
"threshold": 80.0,
"op": "<",
"distance": 16.400000001000002,
"distance_pct": 20.500000001250005,
"direction": "up",
"would_become": "SHORT",
"evaluable": true,
"reason": "single-field change"
},
"flips_closer": false,
"side_changed": false
}
},
"summary": {
"headline": "0 field(s) changed for SOL between 2026-09-02T09:10:45Z and 2026-09-02T09:10:45Z",
"key_points": []
},
"availability": {
"changes": "partial",
"impact": "ok"
},
"warnings": [
"from and to are the same snapshot set; no changes"
]
}Three things in that reply are the honest parts:
fromandtoare the same snapshot set, because this database holds a single cycle. The tool says so inwarningsrather than presenting an emptychangeslist as a calm market.changesis empty andstatusis stillpartial, becauseoi_change_24hdid not resolve on either side. The reason RYO gave is copied through verbatim.impactreports the same side on both sides and an identical nearest flip, which is the correct answer to a diff of a snapshot against itself, and is visibly different from a diff that found nothing to report.