Scores and feedback
A score is what a machine thought. Feedback is what a person thought. mcpd keeps them as two signals and never averages one into the other.
Two signals, not one number
A score is a machine evaluation: a judge model, a regular expression, a unit test, anything that can produce a number about a run. A feedback record is a person's judgement: a thumbs-down in your product, a reviewer's rating, a corrected answer.
Both attach to a trace_id or a span_id, both
arrive as signals in their own right, and both are stored in their own
table. They are
never combined. A judge scoring 0.9 for relevance and a customer pressing
thumbs-down are both true statements, and the disagreement between them is
the most useful thing on the run. An average would erase exactly that, and
leave you with a number nobody can act on.
-
Spans
What ran, nested, with timings
-
Logs
Lines the run emitted
-
Metrics
Numbers, with exemplars back to a run
-
Scores
Machine evaluation
-
Feedback
What a person thought
-
Aspect notes
A sentence a model wrote about the run
A score declares its scale
min and max are part of the score. Without them a
value is not interpretable: 0.86 could be a good result on a 0 to 1 scale,
a poor one on a 0 to 5 scale, or a distance where lower is better. Six
months later, with two scorers in the same project, nobody can tell which.
The spec defaults them to 0 and 1, and every SDK requires them at the call
site so that a default is never accepted by accident.
| Field | What it is for |
|---|---|
scorer_id | Which scorer produced this, such as answer_relevance. Required. |
scorer_version | A score compares only to another score from the same version. Change the prompt of a judge and you have a new scorer, not a continuation of the old one. |
value | The number. |
min, max | The declared scale the number sits on. |
label | A categorical result, when the scorer produces one alongside the number. |
reason | Why. For a judge model this is its explanation, and it is what makes a score arguable instead of magic. |
source | Live, offline, experiment or manual. |
score_trace_id | The trace of the scoring run itself, so a suspicious score can be opened and read like any other run. |
cost | What producing the score cost. |
Recording one
The SDK helper queues a score the same way it queues a span, so the call
does not block and cannot throw. With no traceId given it
attaches to the active span's trace.
score and feedback
// A machine evaluation of the run. `min` and `max` are required: 0.86 on an
// undeclared scale cannot be compared to anything.
mcpd.score({
traceId,
scorerId: 'answer_relevance',
value: 0.86,
min: 0,
max: 1,
reason: 'Answered the question and gave the delivery date.',
})
// A human evaluation. Kept separate from a score, and never averaged with
// one, because the two answer different questions.
mcpd.feedback({
traceId,
kind: 'thumbs',
value: 'up',
comment: 'Exactly what the customer asked for.',
authorKind: 'end_user',
})
// Anything you already count can travel with the trace.
mcpd.metric('support.tickets_handled', 1, 'counter', { labels: { queue: 'tier-1' } }) # A machine evaluation. `min` and `max` are required: a bare number with no
# declared scale cannot be compared to anything.
mcpd.score(
"answer_relevance",
value=round(random.uniform(0.6, 0.95), 2),
min=0.0,
max=1.0,
trace_id=trace_id,
reason="named the order and gave a delivery window, without the live lookup",
)
# A human evaluation. Kept separate from a score, because a person's
# judgement and a model's must never be averaged together.
mcpd.feedback(
FeedbackKind.THUMBS,
"up",
trace_id=trace_id,
comment="fine given the outage",
author_kind="end_user",
) // A score is a machine judgement. The scale is not optional: a bare 0.9 with
// no declared range cannot be compared to anything.
mcpd.score(
Target::trace(&trace_id),
"answer_relevance",
0.9,
Scale::UNIT,
Some("Names the carrier and the delivery day the customer asked about."),
);
// Feedback is a person's judgement, kept separate from a score because the
// two answer different questions and must never be averaged together.
mcpd.feedback(Target::trace(&trace_id), FeedbackKind::Thumbs, "up", Some("fast and correct")); // A machine evaluation of the run. min and max are required: 0.86 means
// nothing until you know the scale it sits on.
m.Score(ctx, mcpd.TraceTarget(traceID), "answer_relevance", 0.86, 0, 1,
mcpd.ScoreReason("Answered the billing question and cited the invoice."),
mcpd.ScoreVersion("2"),
)
// A person's judgement of the same run. Wire this to your product's
// thumbs-up control and it lands on the exact trace that produced the
// answer.
m.Feedback(ctx, mcpd.TraceTarget(traceID), mcpd.FeedbackKindThumbs, "up",
mcpd.FeedbackComment("Fixed it first time."),
mcpd.FeedbackAuthor("u-317", "end_user"),
) // A machine evaluation. `min` and `max` are required: 7 is excellent out of
// 10 and poor out of 100, and a score with no scale cannot be compared.
mcpd.score(
traceId = traceId,
scorerId = "answer_relevance",
value = 0.86,
min = 0.0,
max = 1.0,
reason = "answered the question asked, without the delivery estimate",
)
// A person's judgement, from the thumbs-up control in your own product.
mcpd.feedback(
traceId = traceId,
kind = FeedbackKind.THUMBS,
value = "up",
comment = "fast and clear",
authorId = "customer-4477",
)
// Nothing above touched the network. This is where it goes.
mcpd.flush() # A machine evaluation. `min` and `max` are required: 7 is excellent out of 10
# and poor out of 100, and a score with no scale cannot be compared.
Mcpd.score(mcpd, trace_id, "answer_relevance", 0.86,
min: 0,
max: 1,
reason: "answered the question asked, without the delivery estimate"
)
# A person's judgement, from the thumbs-up control in your own product.
Mcpd.feedback(mcpd, trace_id, :thumbs, "up",
comment: "fast and clear",
author_id: "customer-4477"
)
# Nothing above touched the network. This is where it goes.
:ok = Mcpd.flush(mcpd) # Attach an evaluation. `min` and `max` are required: a bare number with no
# declared scale cannot be compared to anything.
curl -s -X POST "$MCPD/v1/api/scores" \
-H 'content-type: application/json' \
-d "{
\"score_id\": \"\",
\"trace_id\": \"$TRACE\",
\"scorer_id\": \"answer_relevance\",
\"source\": \"live\",
\"value\": 0.94,
\"min\": 0,
\"max\": 1,
\"reason\": \"Names the order and the depot.\",
\"timestamp\": \"$NOW\",
\"correlation\": { \"project\": \"default\" }
}"
Over HTTP that is POST /v1/api/scores, taking a
Score as the body. A score must name the
trace_id or the span_id it is about; a score
attached to nothing is refused with 400 rather than stored where it can
never be found. Send score_id as an empty string and the
server generates one.
Where a score came from
source is separate from scorer_id because the
same scorer runs in more than one context, and the contexts are not
comparable. Re-scoring last week's traffic with today's judge is not
evidence that quality improved.
| Source | Meaning |
|---|---|
live | Produced online, as the traced run happened. |
offline | Produced later, by scoring stored traces. |
experiment | Produced by an experiment run against a dataset. |
manual | Entered by a person, through the API or the interface. |
A score written through an SDK defaults to live. Set it
yourself for the other three.
Scoring costs money, so it is recorded
A judged evaluation is usually another model call. Scoring every production
run with a large judge can cost more than serving the traffic did, and that
is a bill people discover late. A Score therefore carries a
cost in the same shape a span does, with a currency, an input
and output split, and the price book that produced the figure. Set it when
you write the score through the HTTP API or a native ingest batch.
score_trace_id is the other half of the same idea. Trace the
scoring run itself and the id goes on the score, so a judge that started
marking everything down can be opened, read, and shown to be broken.
Human feedback
Feedback is written from your own product. Call it from the control the
person actually pressed and the judgement lands on the exact trace that
produced the answer they were reacting to. kind decides the
shape of value.
| Kind | value |
|---|---|
thumbs | up or down. |
rating | A number, on a scale the producer declares. |
correction | The right answer, as text. |
tag | A label chosen from a set. |
comment | Free prose, with no score attached. |
a thumbs-down from your product
// No traceId: it attaches to the active span's trace.
mcpd.feedback({
kind: 'thumbs',
value: 'down',
comment: 'Named the wrong depot.',
authorKind: 'end_user',
}) curl -s -X POST "$MCPD/v1/api/feedback" \
-H 'content-type: application/json' \
-d "{
\"feedback_id\": \"\",
\"trace_id\": \"$TRACE\",
\"kind\": \"thumbs\",
\"value\": \"down\",
\"comment\": \"Named the wrong depot.\",
\"author_kind\": \"end_user\",
\"timestamp\": \"$NOW\",
\"correlation\": { \"project\": \"default\" }
}"
Redaction matches on key names, and it runs over the metadata
you attach to a judgement. author_id is stored as you send it,
so send a pseudonymous id rather than an email address. See
Redaction and retention.
Scoring what the run did, not what it said
Most scorers read a run's answer. An agent that finishes with I ran all the tests and there were no regressions satisfies every one of them, and satisfies a judge model too, because the sentence is well formed and says the right thing. The sentence is the agent's account of its own work, and an account is not evidence. If the test command never ran, no scorer that reads the answer can tell you so.
Two built-in scorers read the trace instead. They ignore the answer entirely and ask what the run recorded.
| Scorer | Scores 1 when | Configured with |
|---|---|---|
span_present | The run recorded at least one span of the required kind or name. | span_kind, span_name, or both together. Set both and both must hold. |
tool_succeeded | A tool span with that name ran, and at least one call of it ended ok. | tool_name. |
Both need configuration, so neither runs in the default set. A scorer that passed because nobody told it what to look for would report every run as having done work no one checked, which is the failure it exists to catch. Name them, and give them their settings:
{
"trace_ids": ["4bf92f3577b34da6a3ce929d0e0e4736"],
"scorers": ["tool_succeeded", "span_present"],
"config": {
"tool_name": "run_tests",
"span_kind": "tool_call",
"span_name": "run_tests"
}
} span_name and tool_name are compared exactly, and
case_sensitive does not loosen them. A span name is an
identifier your code chose rather than prose, and a loose comparison would
let run_tests_dry satisfy an assertion about
run_tests, on precisely the run the check was written for.
tool_succeeded accepts any of the four tool span kinds,
tool_call, client_tool_call,
provider_tool_call and mcp_tool_call, because
which one a run records says where the tool lives, not whether it ran.
Two ways to score zero, and they are different bugs
A tool that never ran and a tool that ran and failed are both a zero, and
fixing them are opposite jobs: one is a step the agent skipped, the other a
step that broke. The reason separates them, and carries the
recorded error so the score is actionable without opening the run.
| What happened | reason |
|---|---|
| The tool never ran. | "run_tests" never ran; the run records 2 tool call(s), named: lookup_order, send_email |
| It ran and failed. | "run_tests" ran 1 time(s) and none ended ok; the last ended error: pytest exited 1: 3 failed |
| It failed, was retried, and worked. | "run_tests" ran 2 time(s), 1 ended ok and 1 did not |
span_present does the same thing for its own zero: it names
what it looked for and then what the run actually holds, because a wrong
name and absent instrumentation are indistinguishable from a bare
not found and have nothing in common as fixes.
What these scorers prove, and what they do not
They prove an action was recorded. They do not prove it was
correct. A run_tests span that ended ok says the
tool returned without raising, not that it ran the right suite, not that it
ran the whole suite, and not that anything read the result. Treating a green
tool_succeeded as proof the code works is the same mistake as
trusting the sentence, one step further along.
They also measure the trace rather than the world. A span exists because your process recorded one. Work that happened inside a step you never instrumented is invisible here, and reads exactly like work that never happened, which is the one way these scorers report a failure that is not one. When one of them scores zero, check the instrumentation before you conclude the agent lied.
Both hold even when payload capture is off. Neither reads an input, an output or a completion: they read span kind, span name and span status, which the daemon holds whatever your capture settings are.
The query that finds bad answers
This is what the scores are for. has_score names a scorer and
score_below sets the bar, and the trace list returns the runs
that scorer put underneath it. The comparison is strict, so
score_below=0.5 excludes a run that scored exactly 0.5.
find the runs that scored badly
// The same client reads. This is the query that finds bad answers: every run
// that a scorer put below the bar.
const suspect = await mcpd.listTraces({
hasScore: 'answer_relevance',
scoreBelow: 0.5,
limit: 10,
})
console.log(`runs scoring under 0.5: ${suspect.traces.length}`)
const recent = await mcpd.listTraces({ limit: 5, sort: 'start_time', order: 'desc' })
for (const trace of recent.traces) {
const seconds = ((trace.durationNs ?? 0) / 1e9).toFixed(2)
console.log(
`${trace.traceId.slice(0, 8)} ${trace.name.padEnd(16)} ${trace.status.padEnd(6)} ` +
`${seconds}s ${trace.spanCount} spans $${(trace.cost?.totalCost ?? 0).toFixed(6)}`,
)
}
// One run, with every span in it, ordered so a waterfall renders directly.
const detail = await mcpd.getTrace(traceId, { include: ['scores', 'feedback'] })
for (const span of detail.spans) {
console.log(` ${span.kind.padEnd(18)} ${span.name}`)
} # Nothing has been sent yet: recording appends to an in-memory queue and a
# background worker does the I/O. Flush before you read your own writes.
mcpd.flush()
detail = mcpd.get_trace(trace_id, include=["logs", "scores", "feedback"])
print(f"\n{detail.trace.name} {detail.trace.duration_ns / 1e6:.0f} ms "
f"{detail.trace.span_count} spans {detail.trace.error_count} errors")
for span in detail.spans:
cost = f" ${span.cost.total_cost:.6f}" if span.cost else ""
print(f" {span.kind.value:<18} {span.name:<14} {span.status.value}{cost}")
summary = mcpd.analytics_summary("-1h", "-0s")
print(f"\nlast hour traces={summary.trace_count} errors={summary.error_count} "
f"p95={summary.latency.p95:.0f}ms cost=${summary.total_cost:.4f}") // The same client reads. This is the trace you just wrote.
match mcpd.get_trace(&trace_id, Include::all()).await {
Ok(detail) => {
println!("{}: {} spans", detail.trace.name, detail.spans.len());
for span in &detail.spans {
println!(
" {:<22} {:>8} {}",
span.name,
span.duration_ms().map_or("open".to_owned(), |ms| format!("{ms:.1}ms")),
span.status,
);
}
if let Some(cost) = detail.trace.cost.as_ref().and_then(|c| c.total_cost) {
println!(" cost: {cost:.6} USD");
}
for score in detail.scores.iter().flatten() {
println!(" score {}: {}", score.scorer_id, score.value);
}
}
Err(e) => eprintln!("could not read the trace back: {e}"),
} // Recording is asynchronous, so wait for the batch to land before reading
// it back. Outside an example you would never need this.
if err := m.Flush(ctx); err != nil {
log.Printf("flush: %v", err)
}
detail, err := m.GetTrace(ctx, traceID, "scores", "feedback")
if err != nil {
log.Fatalf("read the run back: %v", err)
}
fmt.Printf("%s: %s in %s, %d spans\n",
detail.Trace.TraceID, detail.Trace.Status, duration(detail.Trace.DurationNs), len(detail.Spans))
for _, span := range detail.Spans {
fmt.Printf(" %-18s %-18s %s\n", span.Kind, span.Name, span.Status)
}
summary, err := m.AnalyticsSummary(ctx, time.Now().Add(-time.Hour), time.Now(), "model")
if err != nil {
log.Fatalf("read the summary: %v", err)
}
fmt.Printf("last hour: %d runs, %.1f%% failed, %.4f %s\n",
summary.TraceCount, summary.ErrorRate*100, summary.TotalCost, summary.Currency) val detail = mcpd.getTrace(traceId, include = listOf("scores", "feedback"))
println("${detail.trace.name}: ${detail.spans.size} spans, ${detail.trace.status}")
for (span in detail.spans) {
val ms = (span.durationNs ?: 0) / 1_000_000
println(" ${span.kind} ${span.name} ${ms}ms ${span.error?.message ?: ""}")
}
val summary = mcpd.analyticsSummary(
from = Clock.System.now() - 1.hours,
to = Clock.System.now(),
)
println(
"last hour: ${summary.traceCount} runs, " +
"${summary.errorCount} failed, ${summary.totalCost} USD",
) {:ok, detail} = Mcpd.get_trace(mcpd, trace_id, include: ["scores", "feedback"])
IO.puts("#{detail.trace.name}: #{length(detail.spans)} spans")
for span <- detail.spans do
ms = div(span.duration_ns || 0, 1_000_000)
error = (span.error && span.error.message) || ""
IO.puts(" #{span.kind} #{span.name} #{ms}ms #{error}")
end # Read it back. Any filter in the API works here.
curl -s "$MCPD/v1/api/traces?project=default&limit=10"
# The whole run, with its logs, scores and feedback in one request.
curl -s "$MCPD/v1/api/traces/$TRACE?include=all"
# Find the run by something the model actually said.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'q=Leeds depot'
# Find the runs a scorer marked down. This is the query that finds bad answers.
curl -s --get "$MCPD/v1/api/traces" \
--data-urlencode 'project=default' \
--data-urlencode 'has_score=answer_relevance' \
--data-urlencode 'score_below=0.5'
# Where the money went, over the last week, by model.
curl -s --get "$MCPD/v1/api/analytics/cost" \
--data-urlencode 'project=default' \
--data-urlencode 'from=-7d' \
--data-urlencode 'group_by=model' score_below only means something with has_score
beside it: a bar with no scorer named is a bar on nothing. Both are
parameters of GET /v1/api/traces, so they combine with every
other filter there. Bad answers from one model, in production, in the last
day, is one request.
Reading them back
GET /v1/api/scores lists machine evaluations, filtered by
scorer_id, source, trace_id,
experiment_id, min_value, max_value
and a time window, with cursor paging. source takes
live, offline, experiment or
manual, which is how you read back the judge's own scores
without the ones a person typed.
GET /v1/api/feedback takes a trace_id and returns
the judgements on that run. Listing a project's feedback without naming a
run is not implemented in this build, and the endpoint says so: with no
trace_id it refuses with a 400 naming the parameter. It used to
answer an empty list, which reads as though nobody had judged anything.
The score list takes no order: it is newest first, and its
cursor compares one way only. The run and span lists do take one, so it is
refused here by name rather than accepted and dropped. The same applies to
the kind, from, to and
cursor filters that several SDK clients will let you set on a
feedback query. None of them was ever applied, and all four are now refused.
To read a run and everything attached to it at once, ask for the extras by
name: GET /v1/api/traces/{trace_id}?include=scores,feedback,
or include=all for the logs as well.
What the trace list carries, and what it does not
Each stored trace keeps two rollups so the list needs no join. One is the
mean value per scorer: an average within
answer_relevance, and a separate average within
groundedness. The other is a count of feedback
per kind: three thumbs, one correction. Counts, not a mean, because
the mean of a thumbs-up and a correction is not a quantity.
There is no overall quality score, and there will not be one. A single figure combining a judge model, a regular expression and a customer's thumbs-down would be a number with no unit and no way to argue with it. mcpd shows you the parts and lets you decide.
Next
- Everything else you can filter on: Finding a run.
- What a cost figure is made of: Cost accounting.
- What happens to an author id before it is stored: Redaction and retention.