Skip to content

Commit 4e41ff9

Browse files
anandgupta42claude
andauthored
fix(answer-commit): infer column scale and accept finite decimals (#1516)
* fix(answer-commit): infer column scale and accept finite decimals - Infer the maximum decimal scale per numeric column (by position) when `rows.precision` is omitted, so `5` and `4.18` render as `5.00` and `4.18` in markdown, csv, tsv and lines output. - Explicit `rows.precision` still overrides inference. - Drop the 15-significant-digit limit on numeric values; unsafe integers are still rejected and NaN/Infinity are still rejected by Zod. - Update the tool and schema descriptions and add regression tests. Part of #1481 Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(answer-commit): ignore float-noise cells when inferring column scale - Cells with more than 6 decimals that do not survive 12-significant-digit rounding no longer raise the inferred column scale. - Noise cells render at the inferred scale, or in their own short form when no other cell sets a scale. - Add regression tests for a noise cell among clean cells, a noise-only column, and genuine high-scale values. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(answer-commit): keep genuine long decimals out of the float-noise rule - Treat a cell as float noise only when it is within 1e-7 relative error of its 6-decimal rounding, so float32 and binary artifacts are ignored but genuine long decimals set the column scale. - Add a regression test for genuine long decimals beside clean cells. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(answer-commit): limit float-noise detection to double and float32 artifacts Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * fix(answer-commit): keep decimals more than two ulps from a six-place rounding Co-Authored-By: Claude Sonnet 5.5 <[email protected]> * fix(answer-commit): require every named item as its own field - State in the tool description and analyst prompt that each item the request names (an entity and a related item or count) must be its own field, since basis and prose are hidden under answer-only output. - Add a test pinning the guidance and the answer-only rendering of a complete field set. Co-Authored-By: Claude Opus 5.5 <[email protected]> * fix(answer-commit): keep the rounded scale of recognized float noise - A recognized float artifact now contributes the scale of the shortest value it is an artifact of, so a column mixing it with coarser cells keeps the intended digits. - A column containing only noise cells keeps its values as given. - Add a regression test. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent d8cf208 commit 4e41ff9

3 files changed

Lines changed: 244 additions & 43 deletions

File tree

‎packages/opencode/src/altimate/prompts/analyst.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ Use `training_list` to review learned knowledge. Skills: /teach, /train, /traini
2020

2121
When the request supplies an output JSON schema (the `StructuredOutput` tool is available), finish with `StructuredOutput` instead of `answer_commit` or a plain-text reply; follow that schema. The text finalization rules below apply when no output schema is supplied.
2222

23-
Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for, a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** <requested value(s), with units>`, or `**Answer:** unable to determine — <why>`; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — <why>` if unresolved); never guess.
23+
Finish by calling `answer_commit` with the single chosen value(s) with one field per item the request asks for (every named item, such as an entity and its related item or count, must be its own field: when the request asks for only the answer, anything left in the basis or prose is dropped), a one-or-two-sentence basis (population, grain, source object/column), or `unresolved` with the reason, then reply with exactly the text it returns and nothing else. Always supply `basis`, even when unresolved; it is never shown when the request asks for only the answer. Never list excluded candidates or alternative answers; a top-N with a tie at the boundary still returns N concrete rows (note the tie briefly in `rows.note`); when the request asks for only the answer or a specific format, set `answer_only`. If `answer_commit` is unavailable, finish with one or two sentences of basis and a final line `**Answer:** <requested value(s), with units>`, or `**Answer:** unable to determine — <why>`; when the request asks for only the answer or a specific format, the final message is exactly the requested value(s) or table in the requested format, with no label and no basis (or `unable to determine — <why>` if unresolved); never guess.

‎packages/opencode/src/altimate/tools/answer-commit.ts‎

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import z from "zod"
2+
import { Decimal } from "decimal.js"
23
import { Effect, Option, Schema, SchemaIssue } from "effect"
34
import type { JSONSchema7 } from "@ai-sdk/provider"
45
import { Tool } from "../../tool/tool"
@@ -26,11 +27,8 @@ function optional<T extends z.ZodType>(schema: T) {
2627
const number = z
2728
.number()
2829
.refine(
29-
(value) => {
30-
const digits = value.toExponential().split("e")[0]!.replace(/[-.]/g, "").length
31-
return digits <= 15 && (!Number.isInteger(value) || Number.isSafeInteger(value))
32-
},
33-
"Numeric values must have at most 15 significant digits and integers must be safe; pass the exact value as a string",
30+
(value) => !Number.isInteger(value) || Number.isSafeInteger(value),
31+
"Numeric integers must be safe; pass the exact value as a string",
3432
)
3533

3634
const precisionRange = "precision must be an integer from 0 to 10 (inclusive)"
@@ -64,7 +62,7 @@ const rows = z
6462
"Concrete result rows; when the request names N, the row count must equal N, even with a tie at the top-N boundary",
6563
),
6664
precision: optional(z.record(z.string(), precision)).describe(
67-
"Display decimal places by column name: integer from 0 to 10 (inclusive). Numeric cells use fixed decimals, including integers; strings and nulls are unchanged. Omitted columns keep their default formatting.",
65+
"Display decimal places by column name: integer from 0 to 10 (inclusive). Numeric cells use fixed decimals, including integers; strings and nulls are unchanged. Omitted columns use the maximum decimal scale of their numeric cells.",
6866
),
6967
note: optional(z.string()).describe(
7068
"Brief result note, including any tie at the top-N boundary; omitted when answer_only is true",
@@ -216,6 +214,37 @@ export function renderAnswer(payload: unknown): string {
216214
if (args.rows) {
217215
// altimate_change start - render the requested format before applying the shared size cap
218216
const { columns, values, precision, format = "markdown" } = args.rows
217+
// Float noise: more than 6 decimals, yet either a double-precision artifact of, or the exact float32 widening of, a value with at most 6 decimals (0.30000000000000004, 18.440000534057617). Genuine long decimals (1234567.8901234) are not.
218+
// Two units in the last place of a double: arithmetic artifacts (0.1 + 0.2) sit within it, genuine 15+ digit decimals do not.
219+
const DOUBLE_ARTIFACT_RELATIVE_ERROR = Number.EPSILON * 2
220+
// Returns the shortest (at most 6 decimal) value the noisy cell is an artifact of, or undefined for genuine values.
221+
const artifactOf = (value: number) => {
222+
const decimal = new Decimal(value)
223+
if (decimal.decimalPlaces() <= 6) return undefined
224+
for (let places = 0; places <= 6; places++) {
225+
const rounded = decimal.toDecimalPlaces(places)
226+
// Only a double-precision artifact or the exact float32 widening of the rounded value qualifies.
227+
if (
228+
Math.fround(rounded.toNumber()) === value ||
229+
decimal.minus(rounded).abs().lte(decimal.abs().times(DOUBLE_ARTIFACT_RELATIVE_ERROR))
230+
)
231+
return rounded
232+
}
233+
return undefined
234+
}
235+
// Noise contributes the scale of the short value it is an artifact of, never its raw digits.
236+
const places = (value: number) => (artifactOf(value) ?? new Decimal(value)).decimalPlaces()
237+
// A column with no ordinary numeric cell keeps its values as given.
238+
const onlyNoise = columns.map((_, index) => {
239+
const numbers = values.map((cells) => cells[index]).filter((cell): cell is number => typeof cell === "number")
240+
return numbers.length > 0 && numbers.every((cell) => artifactOf(cell) !== undefined)
241+
})
242+
const scales = columns.map((_, index) =>
243+
values.reduce((scale, cells) => {
244+
const value = cells[index]
245+
return typeof value === "number" ? Math.max(scale, places(value)) : scale
246+
}, 0),
247+
)
219248
const row = (cells: (string | number | null)[]) => {
220249
if (format === "markdown") return `| ${cells.map(tableCell).join(" | ")} |`
221250
if (format === "lines") return cells.map(String).join(" ")
@@ -235,7 +264,8 @@ export function renderAnswer(payload: unknown): string {
235264
cells.map((value, index) => {
236265
const places =
237266
precision && Object.hasOwn(precision, columns[index]!) ? precision[columns[index]!] : undefined
238-
return typeof value === "number" && places !== undefined ? value.toFixed(places) : value
267+
if (typeof value !== "number") return value
268+
return places !== undefined ? value.toFixed(places) : (onlyNoise[index] ? new Decimal(value).toFixed() : new Decimal(value).toFixed(scales[index]!))
239269
}),
240270
),
241271
),
@@ -261,7 +291,7 @@ export const AnswerCommitTool = Tool.define(
261291
"answer_commit",
262292
Effect.succeed({
263293
// altimate_change start - expose format selection to the model before the terminal tool call
264-
description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Numbers are accepted up to 15 significant digits; any decimal that must be reproduced exactly (more digits, trailing zeros, or currency strings) must be passed as a string. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`,
294+
description: `Deterministically finalize a data-question answer. Call this as the LAST tool call, once the answer is decided and all queries are finished. After it returns, reply with exactly the returned text and nothing else. Supply exactly one of fields, rows, or unresolved. Supply one field per item the request asks for, in the order asked: a request for 'the repository and the most copied file' needs two fields (one named for the repository, one for the file); never collapse a compound request into one field, and never add fields the request did not ask for. Every named item (an entity AND a related item or count) must be its own field, because basis, notes and prose are not shown when the request asks for only the answer; an element found but kept only there is lost. Numbers use JavaScript precision; integers must be safe. Pass exact decimal values, trailing zeros, or currency strings as strings. For fields.precision and each rows.precision value, use an integer from 0 to 10 (inclusive). Set rows.precision by column name to display numeric cells with fixed decimal places, including integers; strings are never changed. Without explicit precision, each column uses the maximum decimal scale of its numeric cells. When the request names a tabular format, set rows.format accordingly: markdown (default), csv, tsv, or lines (one row per line with cells separated by a single space, without headers, for single-column or two-column plain lists). CSV/TSV include headers and quote cells containing the delimiter, quotes, or newlines, doubling embedded quotes; null cells are empty. Always supply \`basis\`; it is never shown when the request asks for only the answer. Supply one or two sentences, at most ${MAX_BASIS_CHARACTERS} characters, naming population, grain, the source object/column that supplies the requested entity or value, and caveats. Resolve policy eligibility separately from metric and entity attribution: a rule about a related object does not change which object supplies the requested entity. Say in \`basis\` which source object and column supply it. When the request names N, rows.values must contain exactly N concrete rows, even with a tie at the top-N boundary; note the tie briefly in rows.note. Use answer_only when the request asks for only the answer or a specific format: the reply is exactly the value(s) or table, with no basis, field names, note, or answer label. An unresolved answer retains its unable-to-determine status. Omit unused optional properties. ${renderedLengthGuidance} This tool is not available for JSON-schema requests; use StructuredOutput instead.`,
265295
// altimate_change end
266296
parameters: Parameters,
267297
formatValidationError: (error: unknown) => (Schema.isSchemaError(error) ? error.message : String(error)),

0 commit comments

Comments
 (0)