Changelog
Source:NEWS.md
harnessr 0.6.0.9000
Renamed
The package is now called harnessr. readgpt grew a general core for model calls (clients, spending limits, a response cache, recordings that replay offline, traces) under its document-reading layer, and the new name says so. Load it with
library(harnessr). The GitHub repository is renamed toelkronos/harnessrtoo, and GitHub sends the old address there, soremotes::install_github("elkronos/harnessr")installs it and the oldelkronos/readgptstill works. The website moves to https://elkronos.github.io/harnessr/. No exported function, class, argument or prompt changes name in this release exceptreadgpt_example(), below.readgpt_example()is nowgr_example(), which returns the same path to the bundled example document.readgpt_example()still works, with onegr_deprecatedwarning per session naming the new function.Requests, cache keys and recordings are unchanged by the rename. Every request is sent as 0.6.0 sent it, and the corpus store key is the one 0.6.0 computed. The response-cache and replay keys change later in this release (see “Cache and replay keys” below), and the keys 0.6.0 computed are still looked up, so a warm cache, a recording made for
gr_replay_client()and agr_read_many()store all keep answering.Two options are renamed:
readgpt.api_keyis nowharnessr.api_key, andreadgpt.allow_local_urlsis nowharnessr.allow_local_urls. The old names are still read when the new ones are not set, with onegr_deprecatedwarning per session naming the new option.The help pages now suggest
tools::R_user_dir("harnessr", "cache")for a cache that lasts. With that directory set, entries readgpt kept intools::R_user_dir("readgpt", "cache")are read too, after it; new entries are written only to the new directory, and the old one is never cleared. The per-session default is nowfile.path(tempdir(), "harnessr-cache").Text that named the package now says harnessr: the URL fetcher’s user agent (
harnessr/<version>), the audit report’s title and footer, messages, and the Shiny app’s title. A trace’smetastill records the version underreadgpt, so traces written before and after the rename read the same way.
Traces record what was sent and what came back
Each request step records
params_sent, the request as it went out with every default resolved: the model,api,base_url, the output cap actually sent (a reasoning model’s small cap goes out raised to 2048), the temperature (filled fromgr_options("temperature"), orNAwhen none was sent), the effort, the schema name, and SHA-256 hashes of the schema and of the extra body fields. For agr_client()it is read from the request body itself.paramskeeps what the caller asked for, as before. Requests, response-cache keys and replay keys are unchanged.Steps record
served_model,response_idandfingerprintwhere the provider returns them, besidemodel, which stays the model asked for and is what a step is priced and replayed by. None of them enters a cache or replay key.gr_call()results carry the same three fields.Steps record
attempts, the number of requests sent, retries included (0 for a reply from a cache or a recording), andstarted_utcandended_utcas ISO 8601 in UTC with a trailingZ. A saved trace’sstartedis now written in UTC with aZtoo; it was local time with no offset.Token records say where they came from. A step’s
tokensgainsprovenance:"reported"by the provider,"counted"here with the"tiktoken"tokenizer, or"estimated"with another one. Reasoning and cached input tokens the provider reports are kept astokens$reasoningandtokens$cached_input, parts ofoutputandinputrather than additions.gr_trace_summary()gainstokens_reasoning,tokens_cached_inandusage_estimated, the share of requests whose counts were estimated, andprint()on a trace says that share when it is above zero. A request that failed before the provider sent any usage records a"counted"zero, and a handler’sgr_result()built with no usage is counted here rather than taken as a reported 0 in and 0 out.Embeddings requests record
attempts,served_modeland a provenance like any other request;attemptswas alwaysNAalthough they are retried, and a failed one had no provenance.as.data.frame()on a trace gainstokens_reasoning,tokens_cached_inandserved_modelcolumns when some request has them, andgr_trace_cost()gainspaid_reasoningandpaid_cached_in.Cached input can be priced at its own rate.
gr_register_model()takescached_input_usd,gr_estimate_cost()takescached_input_tokens, andgr_models()showscached_input_usd_per_1m. A model with no cached rate prices cached input as other input, so every built-in model costs what it did.Runs record who they are for.
gr_trace()takessystem,owner,run_by,purposeandapproved_by, and the new optiongr_options(actor = list(...))sets them for every trace; a field named nowhere isNA. They are written to a saved trace underactorand shown byprint()when set.Runs record the options in force when the trace was made, under
config, with the R version. The values ofapi_headersare left out, since they can hold credentials.gr_options()given one named argument whose value is a list now sets that option. It used to read the list as a set of options to restore, which only an unnamed list should be.
Cache and replay keys
gr_call()takes adraw_indexfor the draws of a sampled request: 0, the default, for an ordinary call, and 1, 2, … for repeated draws. Above 0 it is part of the response-cache key, so N identical calls with draw indexes 1 to N are N cached replies; before, a cache returned the first reply N times and anything that compared them saw perfect agreement. A handler finds it inparams$draw_indexand the trace records it in the step’sparams; at 0 neither carries it, so an ordinary call is unchanged.The response-cache key is now
harnessr-cache-v3, a SHA-256 hash (64 hex digits) in place of xxhash64. Entries written under the old key (readgpt-cache-v2, by readgpt 0.6.0 and earlier builds of harnessr) are still read for calls with no draw index: a call that finds nothing under its new key is looked up under the old one. The first reply found that way in a session warns once for the cache, with classhr_legacy_key, andgr_cache_stats()has a new column,legacy_hits, that counts them. Old entries are never rewritten, new ones are written under the new key only, andgr_cache_clear()removes both.The replay key is now
harnessr-replay-v2, and covers the temperature (once the session default is filled in), the output cap asked for and the JSON schema as well as the messages, model and effort. A replay under another temperature, cap or schema now misses instead of being answered with the recorded replies. Each model step of a trace records its key asreplay_key. A step recorded before that is looked up by the old key (readgpt-replay-v1: messages, model and effort), with onehr_legacy_keywarning per recording in a session and a count in$stats()$legacy_hits. A replay’s own trace records a reply found that way, or one repeated underon_exhausted = "flag", without areplay_key, so a replay of that trace still goes through the old key, or still runs out of replies. A miss on a prompt recorded for the model at the effort asked for says the temperature, output cap or schema differ, names the recorded settings next to the ones this call asks for, and says which of them differ. Where the schema differs, the recorded cap is given as the cap that was sent, which may be the model’s limit rather than the cap asked for.A replay that runs out of recorded replies is an error. A call past the last reply recorded for its request, or for a draw that was never recorded, raises
hr_replay_exhausted. It used to repeat the last reply, so a replay making more calls than the recorded run looked like an exact reproduction.gr_replay_client(on_exhausted = "flag")keeps the old behaviour, counted in$stats()$repeats. A call with adraw_indexabove 0 is answered with the reply recorded for that draw, or the k-th reply to the request in a recording made without draw indexes.
Limits checked before every request
max_callsandmax_cost_usdnow hold exactly. Every request is checked before it is sent, by the call path itself, against what the run has spent plus the most the request could cost: its prompt at the input price and its output cap (for a reasoning model, the raised capgr_call()sends) at the output price. The prompt is counted with the JSON schema and extra body fields the request carries and a few tokens of framing per message, then raised bysafety_margin, since the provider bills for all of that. A request that could take the run past a limit is not sent. Runs used to stop only once what they had spent reached the limit, so they ended up to one request over it; they now end at or under it, as long as the provider stops each reply at its cap and counts the prompt within that margin of the local tokenizer. A run that leaves its output caps at the model’s limit stops while it still has headroom a smaller cap would have used.A refused request is not a call. It comes back from
gr_call()as a failed result withrefused = TRUEand an error naming the limit, after a condition of classhr_budget_refusedis signalled (catch it to stop instead). It adds nothing to the trace’s calls, steps or spend; the trace lists it in a newrefusalsfield, setsbudget_stopand names the limit instop_reason. Readers and review stages treat it as they treated reaching a limit before: the answer or section is marked partial, and the request is not counted as failed.Every request is checked, not only the ones a reader asked about. Judges, the ensemble’s adjudication, conflict resolution in
extract, context and proposition segmentation, claims, outlines, screening, synthesis and embeddings requests all go through the same check, as does any request a custom reader or a script makes withgr_call(). The readers no longer checktrace_can_call()themselves. It is kept for custom readers written to the 0.6.0 help page, which still work.A chat model with no registered price is refused while a spending limit is set, with a condition of class
hr_unpriced_model. Such a model added nothing to the spend, so the limit never stopped it.gr_read()stops before its first request with anhr_unpriced_modelerror. Register the model’s prices withgr_register_model(), or setgr_options(allow_unpriced = TRUE); then its requests are sent, still count againstmax_callsandmax_tokens_total, and are pricedNAin the trace.gr_mock_client()and replays are never refused for this, and neither are embedding models with no price. An ellmer chat is priced as the model it was built with, whatever a spec names, so a chat on a model with no registered price is refused too; the README and thegr_ellmer_client()example now register its price, and allow a local model to run unpriced.New option
max_tokens_total(NULL, off) limits the tokens a run may use: prompts and replies of every call, and text sent to be embedded. A request whose prompt and reply at its cap would pass it is refused withstop_reason"tokens". It is the limit that still bounds a run on an unpriced model underallow_unpriced = TRUE.gr_compare()holds the whole comparison to it, as it does tomax_cost_usd.New
hr_governor()holds a run’s limits:max_calls,max_cost_usd,max_tokens_total,max_output_tokens(the largest output cap one request may carry; a request with a larger one is refused, not cut down),allowed_modelsandallow_unpriced. Attach one with the newgr_trace(governor = )argument; a trace without one reads the limits fromgr_options(), as before. The new limits are also options,max_tokens_total,max_output_tokens,allowed_models(allNULL, off) andallow_unpriced(FALSE), so every trace’s config snapshot now lists them.as_json()writes a trace’srefusalsandgovernorwhen it has any, withallowed_modelsalways as an array.gr_read_many(),gr_claims(),gr_outline()andgr_synthesise(), which keep a trace of their own and fold it into the one they are given, hold their requests to that trace’s governor, less what it has already spent and used in tokens;max_callsstays a limit per document and per stage.A parallel batch cannot pass a limit. Each worker’s items are held to an equal share of the calls and dollars the run has left, and an item its share refused before it sent anything runs again in the calling process, against the run. A batch that the run’s limits cannot cover still runs in the calling process, as before. Batch items make one request each; an item that made more and had a later one refused by its share warns (
gr_parallel_share) and does not mark the run as stopped.
JSON replies checked against their schema
-
A JSON reply must fit the schema the request sent. Every call that asks for JSON (screening, claims, outlines, preview plans, rerank, iterative reading, model segmentation, extraction) now checks the reply’s types,
enumvalues andrequiredfields, through nested objects and arrays, before using it. A reply that fails is treated as a reply that did not parse: the reader takes its failure path, and the reply is not cached, so running again asks again. A cached reply that fails is asked again too. It used to be enough for a reply to be a named object, so{"decision": "probably"}was a screening reply and was stored as one. Some replies that used to be read leniently are now failed calls: a screening decision outsideinclude,excludeandunclear(it was read as “unclear”), a text field sent as an array or object initerative,rerankandgr_screen()(it was joined into one string), and a proposition list sent as an array of arrays (the batch is kept as written). Where a reader reports why a call failed, it now says where the reply broke the schema, as in$.groups[0]: expected array, got 1.gr_extract()still reads each field’s value itself, so “1,204” in an integer field is still 1204 and an undeclared value is still reported as one it could not read; only the shape of its replies is checked.
Embedding scores
Comparing vectors of different lengths is an error. Cosine similarity cut both vectors to the shorter length, so a lexical fallback vector scored against an API embedding gave a plausible number from two unrelated spaces. It now stops with an error of class
hr_dim_mismatch. The readers already kept the query and the chunks in one space;iterativenow also falls back to word overlap for a round whose query came back with no vector.BM25 scoring is faster on many chunks. Document frequency is counted once per query term instead of once per term for every chunk, so the
rerankprefilter and the offlineretrievepath no longer slow down with the square of the number of chunks. Scores are unchanged.
Parameter schemas, declared calls and reader costs
New
hr_schema(),hr_param()andhr_validate()state a spec’s parameters once: type, default, range and choices.gr_read_spec(),gr_segment_spec()andgr_ingest_spec()now check their arguments through schemas instead of code of their own. They accept the same values and return the same specs, with the same clamps, defaults and warnings; a clamp warning now also has classhr_param_clampedbesidegr_clamped.A value outside the choices of
context_order,restate,on_overflow,context_source,ocrorlayoutis still an error, which now names the setting and has classgr_bad_setting, where it used to be a plainmatch.arg()error that said'arg'.Readers and segmenters declare the labels their calls carry.
gr_register_reader()andgr_register_segmenter()take a newcallsargument, the stems of their calls’ trace labels ("stuff.answer",c("map.answer", "reduce")).gr_readers()andgr_segmenters()list them in a newcallscolumn. A reader or segmenter registered withoutcallsdeclares its own name, which covers labels such as"longest.answer"for a reader called"longest".An
effortmap that names no declared call is an error. A name ingr_read_spec(effort =)(and theeffortofgr_claims(),gr_outline()andgr_synthesise()) must be a declared stem, a part of one up to a dot ("synthesise","segment"), or a label under one ("reduce.level1"). Anything else stops with an error of classhr_unknown_effort_keythat lists the stems. Such a name used to be accepted and matched no call, so a misspeltc(extrac = "low")sent every extraction call at the provider’s default with nothing said. An effort map that names the calls of a reader or segmenter of your own needs that module registered first, with its labels incalls.A reader states its cost as a function.
gr_register_reader()takes a newcostargument, a function of(input_stats, params)returning the newhr_cost(): requests expected and at worst, embedding requests, and what the reader must send. The pre-flight check ofgr_read()adds up these functions instead of repeating each built-in reader’s call pattern, and the governor decides from the sum whether the run fits its limits before the first request. The built-in readers’ estimates, the trace’spreflightstep and the refusals are unchanged. A registered reader withoutcostis counted as before, one request per chunk.cost_callsis kept for display only, as the descriptiongr_readers()shows; the pre-flight check never reads it, and it will be retired in a later phase. A cost function may also return a plain list withcalls_maxand nocalls, which is read as both.hr_cost()has atokens_out_maxfor a reader whose replies can be billed beyond the reply cap; the check prices every request atinput_stats$out_cap, the largest cap the run’s model is sent, and usestokens_out_maxwhen it is larger.hr_param()with atypeortierit does not know is an error of classgr_bad_settingthat names the parameter. Anamed_mapparameter takes a character vector or a list whose elements are all named.
Screening
gr_calibrate()computes kappa on kept against excluded. 0.6.0 took kappa over the screener’s three labels (include, unclear, exclude) against the person’s two, so every"unclear"counted as a disagreement, while sensitivity in the same object counted an unclear record as kept. The primary table is now kept (include or unclear) against excluded, the definition sensitivity uses, and$agreement$strictholds strict include against the rest as a secondary table. On the eight-record example in?gr_calibratekappa moves from 0.33 to 0.50. The old figure is$kappa_legacyfor this release only, and is removed in the next.Kappa is no longer reported alone. Each agreement table carries Cohen’s kappa, PABAK and Gwet’s AC1 with intervals, the prevalence and bias indices, and the class-wise sensitivity, specificity, PPV and NPV;
$agreement$kept$paradoxflags a table whose prevalence index is above 0.8 in absolute value, where kappa says more about prevalence than agreement.$decisionsholds the 3x2 counts, andprint()and the audit report show them with the agreement set. The rate of"unclear"decisions is the existing"deferred to a person"metric, with its interval.The kappa interval is a bootstrap over documents. It is a percentile bootstrap with B = 2000 under a fixed seed, so the same sample gives the same interval and the session’s random stream is untouched; B and the seed are in
$agreement$kept$bootstrap. AC1 keeps Gwet’s (2008) variance. Where the screener and the person agree on every record, disagree on every record, or the sample holds fewer than 10 records, the kappa and AC1 intervals are left empty (NA) with a$notesaying why, instead of a zero-width interval such as [1.00, 1.00].print()on a calibration now shows the 3x2 counts on every sample, including one drawn from a single stratum. The audit report’s calibration section shows the same counts, and sensitivity, specificity, PPV and NPV with intervals beside kappa, PABAK and AC1.Unclear records no longer leave a review without a word. A
gr_screeninggains$forward(the included and unclear records, the set a review reads at full text) and$deferred(the unclear ones).gr_extract(screening)still reads only$includedin this release and now warns (classgr_unclear_dropped) when that leaves unclear records out; from the next release it reads$forward. Passscreening$forwardto read them now. Passing the vectorscreening$includedgives no such warning:$includedstays a plain character vector in this release, and a warning on that path is left to a later release.New
gr_resolve()records a person’s decision on a deferred or failed record: who decided, when (in UTC) and why, in the table and as a"screen.resolve"step in the trace.forward,deferredandincludedare rebuilt, the screener’s own decision is kept inscreener_decision, andgr_calibrate()compares against that one, so a person’s correction is not credited to the screener. Duplicates of a resolved record take the same decision. The audit report’s screening table shows the screener’s decision and who resolved each record, when and why.gr_flow()accounts for every screened record. A record with no decision is now counted as “could not be read” only when no screening call was made; one whose call failed, or whose reply was not the form asked for, has a row of its own, “screening call failed”. New rows count the records resolved by a person, the records kept for full text ($forward) and, of those, the ones deferred ($deferred).
Claims and outline
gr_claims()andgr_outline()record their calls the way the other review stages do. Each records on a trace of its own and, giventrace =, adds its calls to that trace when it finishes, asgr_screen(),gr_extract(),gr_read_many()andgr_synthesise()already did. Before, both recorded straight onto the trace they were given, so a review trace that an earlier stage had taken pastgr_options(max_calls =)ormax_cost_usd =stopped them before they sent a request, andgr_claims()returned no claims, marked partial. Pass one trace to every stage and its totals are the sum of the stages’, with each request counted once.gr_outline()no longer adds toclaims$trace. Given notrace, it recorded its calls on the trace of the claims it was handed, which changed a finishedgr_claims()result and madeclaims$tracereport the outline’s cost as the claims’. Its calls are now onattr(outline, "trace"), whichgr_outline_pick()keeps andgr_synthesise()keeps as$outline_trace. Scripts that totalled a review’s cost fromclaims$traceaftergr_outline()should pass onetraceto both, or addattr(outline, "trace"). The audit report’s claims row still includes the outline’s calls, read from the synthesis, and counts them once when a script passestrace = claims$tracetogr_outline().
Comparing recipes
gr_compare()says what each recipe cost.summarygainscalls,tokens_in,tokens_outandusd, priced from the requests on the trace at each model’s registered price, andusd_estimated, the share of those dollars spent on requests whose token counts were estimated locally because the provider reported none (NAfor a trace whose requests do not record how their counts were obtained, such as one saved by an earlier version). A recipe is charged for a segmentation it shares with an earlier recipe, as it would be run alone, so the column can add up to more thangr_trace_cost(cmp$trace).usdisNAwhen a model has no registered price. The columns come aftererrorand beforepremise.gr_compare(correct =)takes strings that a right answer contains (ignoring case and white space) or a function of eachgr_answerreturningTRUE,FALSEorNA, and addscorrectandusd_per_correct, the cost of a right answer:usdwhen the answer is right,Infwhen it is wrong and cost something. Without it the summary has no such columns. A recipe a limit stopped before it found anything isNA, not wrong: the spending, token or call limit, the model allow-list, the output ceiling, or a spending limit refusing a model with no price. Acorrectfunction that fails or returns something other than one logical gives agr_bad_correctwarning andNAfor that recipe, so the paid results still come back; an invalidcorrectargument is an error of the same class.
Checking quotations
gr_read_spec(verify = TRUE)checks an answer’s quotations against the chunks they come from and lets the check decide what is accepted.gr_read()runsgr_verify_evidence()on the answer, writes its columns onto$evidence, marks the answer partial when a quotation is not found (notes$unverified_evidence), notes how many it checked (notes$quotes_checked) and records aread.verifystep in the trace. Theiterativereader’s step must then list the passages its answer rests on, word for word with their chunk numbers, and the loop stops only when every one is found in the chunk it names, among those the step was shown. A refused step is told which quotations were not found; a run that ends with none accepted answersNOT_IN_DOCUMENT, keeps the refused answer innotes$unverified_answerand makes no final answer request. Its evidence is the quoted passages, not every chunk it was shown. A refusal is sent back even when retrieval has no unseen chunks left, so the next step can quote what it already has. Withpremise = TRUE, a step that reports the premise false is not held to quotations in this release and stops the loop as it does withoutverify.verify = FALSE, the default, sends every request as before, and the field is in the spec only whenTRUE, so cache, replay and corpus store keys are unchanged.A second quote matcher is recorded beside
verified. Evidence tables that are checked (skim,extract,screen,iterativewithverify = TRUE) andgr_verify_evidence()gainquote_status,quote_sim,quote_coverage,quote_editsandquote_critical_ok, from a matcher that expands ligatures, joins words broken across a line with a hyphen, undoes JSON escapes and scores the closest window of the source by character edits, with numbers (in digits or in words, with any comparison sign before them and the unit after), and negations required to be identical. A passage too long to score (its length times the source’s over 5e7 characters) isnot_checked. Its statuses areverbatim,near_verbatim(similarity at least 0.90, or 0.95 under 40 characters, five words or more),unverifiedandnot_checked. It decides nothing in this release:verifiedandmatchare the existing check’s, as before. On a labelled set of 343 true and 162 false quotations it rejects 1.2% of true ones (95% CI 0.3% to 3.0%) against 36.4% (31.3% to 41.8%) for the existing check, and accepts none of the 52 with a changed digit, 8 with a changed number word, 3 with a changed comparison sign, 37 with a flipped negation or 16 invented sentences; it does accept 25 of 46 quotations with one content word swapped asnear_verbatim, which is why that status is notverified.Rscript dev/quote-matcher-rates.Rprints the full table.
Trace records of state and cuts
refineandhierarchicalwrite the length of the state they carry. Eachrefine.draftandrefine.revisestep in the trace gainsstate$tokens_in(the draft the request carried) andstate$tokens_out(the draft carried on from it, which is the old one when the reply was unusable). Eachhier.summarise.L<k>step gains the length of the text it summarised and of the summary it returned, thehier.answerstep the length of the summaries it was sent, and each level adds a localhier.state.L<k>step with the number of summaries handed on and their combined length. A draft that shrinks sharply in one revision can now be seen in the trace.Every cut writes a
truncatestep to the trace with where it was made (detail$where:refine.excerpt,refine.draft,hier.summaries,preview.skim,preview.outline,<label>.inputfor a finding cut to fit a merge,<label>.giveupfor the capped findings a failed merge returns,segment.contextual.header,segment.contextual.document,screen.opening,synthesise.gaps,embed.input), the token limit, and the tokens before, kept and dropped (the truncation marker is not counted as kept). Forscreen.openingthe limit is the screening cap on the opening with its heading; forsegment.contextual.headerit is the room the header was cut to. Apreview.skimcut lists the cut section’schunk_ids, always as a JSON array.refineandhierarchicalanswers also gainnotes$tokens_truncated, and their printed answer now says how many tokens were cut (“N token(s) cut to fit”) when a cut happened. Thepreviewreader’snotes$tokens_truncatedis now the tokens a cut dropped rather than the overshoot over the cap, a correction to a count that measured the wrong thing. Requests, cache keys and replay keys are unchanged; the trace of a run that cut nothing gains only the state lengths above.
Conformance checks
New
gr_check_reader()andgr_check_segmenter()run one registered reader or segmenter ongr_example()through an offline mock client and report which rules of its contract it breaks. A reader is checked for finishing without an error, returning agr_answerwith the fieldsgr_read()documents and a single string as its answer, evidence andchunks_usedthat name only chunks of the input, call labels covered by the stems it declared incalls, and no more requests than itscostfunction’s worst case for that input. A segmenter is checked for finishing without an error, returning agr_chunkswith the documented columns, text in every chunk, unique chunk ids, no chunk overmax_tokensthatgr_segment()had to split, and call labels covered by its declared stems. Every built-in reader and segmenter passes them.New
hr_check_module()is the core helper both use: it runs a closure that calls a module in a temporary working directory, catches its error and warnings, asks a named list of rule functions about what came back, and returns anhr_conformancedata frame with arule,okanddetailfor each.okisNAfor a rule that could not be checked.print()lists the broken rules first, says “passes all N rules” when every rule passes, and says “passes all N rules checked; M not checked” when some rules could not be checked.The checks are reports only.
gr_register_reader()andgr_register_segmenter()still check only thatfnis a function, and register a module that breaks a rule as before.