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/list and tools/call. Arguments go in the arguments object; 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.

Each argument of evidence_diff, its type, whether it is required, and what happens when it is omitted.
ArgumentTypeRequiredIf omitted
symbolstringconditionallyRequired unless both snapshot set ids are given. With neither, status becomes unavailable, because there is nothing to diff.
sincestring, ISO-8601nofrom 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_setstring, snapshot set idnoFalls back to since, then to the earliest snapshot set on record.
to_snapshot_setstring, snapshot set idnoFalls back to the latest snapshot set for the symbol.
strategy_idstring, strategy idnodata.impact is null and availability.impact is unavailable. An unknown id adds a warning and still returns null rather than failing.
schema_versionstringnoServes 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 forces status to partial. Nothing is zero-filled to keep the list tidy.
data.impact
Present only when a strategy_id was 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 since that matches no stored snapshot falls back to the earliest one and says so.
  • A strategy_id that does not exist adds strategy <id> not found and returns data.impact: null.
  • No snapshot set at all for the symbol returns status: unavailable with 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:

  • from and to are the same snapshot set, because this database holds a single cycle. The tool says so in warnings rather than presenting an empty changes list as a calm market.
  • changes is empty and status is still partial, because oi_change_24h did not resolve on either side. The reason RYO gave is copied through verbatim.
  • impact reports 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.

Which fields resolve, How a receipt is verified