The outcome extractor
The engine consumes records. A test run leaves a run bundle: a directory
that holds the test report, the revision and the dirty flag of each checkout
the run used, the run name and the command.
affirmatrix.sources.outcomes.TwisterOutcomeExtractor turns each bundle
into one TestOutcome record for each test result the report holds, and into
the edges that tie each outcome to what it confirms and what it witnesses. The
command line composes it into the current stream, and only for the verbs that
judge evidence (see the last section).
The case stores none of these records. They are built when a verdict is made, from the bundles the command line names, and they are not kept (SEG-SYS-013).
The run bundle
A run bundle is a flat directory. The extractor reads these files and no other (SEG-SREQ-219):
File |
Content |
|---|---|
|
the run artifact: a JSON object with a |
|
the name of the run, one line |
|
the full revision of the checkout, one line, for each checkout the run used |
|
empty when the checkout was clean; otherwise the output of the status command |
|
the command of the run (provenance only; not read) |
Every other file of the directory belongs to the identity of the bundle (see
the next section) and is not read. A bundle that the repository evidence
holds, for example, looks like this:
clean/
command.txt
run.name
testplan.json
twister.json
twister_report.xml
twister_suite_report.xml
toolbox.dirty
toolbox.sha
zdocs.sha
zephyr.dirty
zephyr.sha
The top-level configuration key implementation names the checkout that
decides. Its .sha file is the revision of every outcome of the run
(SEG-SREQ-186), and its .dirty file decides whether the run is
refused (SEG-SREQ-222). A dirty flag of any other checkout is accepted:
the bundle records it and nothing judges it. A missing .sha file of the
implementation checkout refuses the run (SEG-SREQ-187). So does a missing
.dirty file of it, because a bundle that says nothing about that checkout
does not show it clean. A flag that holds only blanks is clean. When no
implementation is configured, a named bundle is refused.
extractor = TwisterOutcomeExtractor(
[Path("evidence/clean")],
checkout="toolbox",
specifications=config.SpecificationInputs(
export=Path("needs/test-specification/needs.json"),
doxygen=Path("xml/dox-safe-data-testspec"),
),
implementations=config.ImplementationInputs(
export=Path("needs/api-traceability/needs.json"),
doxygen=Path("xml/dox-safe-data-api"),
),
)
Each input has one purpose:
the run artifact is the authority on what a test did. Its
testsuiteslist holds one suite for each scenario. A suite names its scenario and its platform and lists its results, each with a test identifier and a status (SEG-SREQ-176, SEG-SREQ-224);the name and the revision come from the bundle’s own records, never from a loose file beside it. The artifact holds neither the full revision nor the name of the run (SEG-SREQ-223);
the test-case export is the authority on which specification a result belongs to (SEG-SREQ-180);
the implementation export gives the witnesses (SEG-SREQ-189). When
implementationsisNone, no Witnesses edge is supplied.
Each of the two exports is filtered by the types of its input before any
need is checked. Only a need of a configured type is a test-case need or an
implementation need (SEG-SREQ-336, SEG-SREQ-339). A need of
another type is not checked, not mapped and gives no edge, so a bad field in it
cannot refuse a run (SEG-SREQ-338). The extractor reads the types from
the input classes it already holds, so it needs no argument of its own. With no
types, every need is read (SEG-SREQ-337, SEG-SREQ-340).
The input of the implementation export may also carry a list of need identifiers. When it does, a need gives a Witnesses edge only when its key is in the list (SEG-SREQ-359). A need that the list names and the types do not admit counts as unknown. Both filters apply before any need is checked, so a need outside them is never refused for its shape (SEG-SREQ-375). Every listed identifier that no admitted need carries is named in one error, one line for each, when the extractor is built (SEG-SREQ-361). An outcome with no listed implementation among those that satisfy the requirement it verifies is set aside like any outcome that no implementation confirms (SEG-SREQ-355, SEG-SREQ-356).
The doxygen field of the two input classes is not used. A bundle can lie
anywhere; the extractor needs no root and no repository name for it
(SEG-SREQ-134). The report format built is a JSON test report; a pytest
run would be a second format of the same component. It is not built.
The digest of a bundle
A bundle is identified by its digest, not by its name. A name is chosen by a person and can repeat; a digest follows the bytes. Nobody gives the extractor a digest to check: it computes the digest of every bundle it is given, whatever its revision, on every run of a verb that reads bundles, and it reads every file each time. The digest is the repository member of the anchor of each outcome (see below), and a proof records it (see The evidence package).
The digest is built from a list with one line for each regular file under the bundle directory:
the SHA-256 of the file’s bytes, in 64 lowercase hex digits;
two blanks;
the path of the file relative to the bundle directory, with
/as the separator;one line feed.
The lines are in the byte order of the UTF-8 paths (LC_ALL=C order). The
digest is the SHA-256 of all the lines, written as sha256: and 64 hex
digits (SEG-SREQ-220). A changed byte, a renamed file and an added
file, an empty one included, each change the digest. The order of the files on
disk, the location of the bundle and the modification times do not.
Anyone can make the same digest without the tool. From the bundle directory:
find . -type f -printf '%P\n' | LC_ALL=C sort | xargs -d '\n' sha256sum | sha256sum
For the bundle clean above the command prints
f960023c5eacbe8c4a2fc2867d4d78c38bede4c02b1df6ac150f5804c8ca0043.
The function bundle_digest() makes the same
value. Some cases would make the shell recipe and the tool differ, so the tool
refuses them: a link, a file that is neither regular nor a directory, a file
it cannot read, and a path that holds a backslash or a line feed (sha256sum
writes such a name in an escaped form). An empty directory holds no file and
does not count.
Identity
An outcome is identified by its run identifier and its specification
identifier, joined by a slash (SEG-SREQ-177). The run identifier joins the
name of the run, the platform and the scenario by hyphens (SEG-SREQ-178). Each
slash of the platform becomes a hyphen, so native_sim/native/64 is
native_sim-native-64 and the identity holds one slash. The specification
identifier is the identifier of the test-case need, verbatim (SEG-SREQ-179).
For the first result of the scenario safe_data.api the identity is:
twister-run-2026-09-29-native_sim-native-64-safe_data.api/TC_SAFE_DATA_INIT_AND_VERIFY
The identity is never split to recover its parts. A scenario name can hold dots and hyphens, and a platform can do so as well, so the parts do not show where they end.
The name must be one line, without a slash and without blanks around it. The operator chooses it and keeps it unique for each run.
Mapping a result to its need
The test-case export is read once. For each test-case need the extractor keeps
its suite and its test function without the prefix test_. For each suite of
the artifact it forms, from every need, the identifier
<scenario>.<suite of the need>.<test function without test_>
and compares the result’s test identifier with each formed identifier as a whole (SEG-SREQ-180). Exactly one need must form it. No need and more than one need are both an error that names the result (SEG-SREQ-181). The extractor never cuts a test identifier into parts.
The reason is that one string can have two readings. Take the scenarios s
and s.t. One need has the suite t.u and another has the suite u,
and both have the test function test_f. The identifier s.t.u.f is the
one the first need forms in the scenario s. It is the one the second need
forms in the scenario s.t. The scenario is part of what is formed, so each
result finds its own need. Two needs with the same suite and the same test
function are ambiguous only when a result reaches them. A scenario without results supplies nothing.
The status
Status in the artifact |
Result of the outcome |
|---|---|
|
passed |
|
failed |
|
error |
|
skipped |
The table is fixed (SEG-SREQ-183). Any other status, such as blocked
or not run, is an error that names the result (SEG-SREQ-184). The
extractor checks the status of every result of an artifact first, before any
result is mapped to a need. If any status has no counterpart, one error names
every such result, each with its status, its board and its scenario, and no
result is reported as unmapped in that error. A skipped result is recorded as an
outcome with the result skipped; it is not dropped (SEG-SREQ-185). The
revision of each outcome is the text of the revision record, as it is
(SEG-SREQ-186). The extractor does not check its length.
The canonical record
The content hash of an outcome covers three values and nothing else
(SEG-SREQ-182): the specification identifier, the run identifier and the
result. The execution time and the reason of a result are not hashed. The hash
is the SHA-256 of the canonical record, a JSON object in UTF-8 with sorted keys,
no blanks and no escaping of non-ASCII text (RFC 8785). For the result
safe_data.api.safe_data.init_and_verify of the scenario safe_data.api
the record is these 132 bytes:
{"result":"passed","run":"twister-run-2026-09-29-native_sim-native-64-safe_data.api","specification":"TC_SAFE_DATA_INIT_AND_VERIFY"}
Anyone can reproduce the hash without the tool:
printf '%s' '{"result":"passed","run":"twister-run-2026-09-29-native_sim-native-64-safe_data.api","specification":"TC_SAFE_DATA_INIT_AND_VERIFY"}' | sha256sum
The command prints
b21f9c415e6b2de8ea131a18c709acdface0c91c976041b752ae382bf3128869. The hash
does not cover the bytes of the artifact, so recomputing it needs the name of
the run and the test-case export as well as the artifact. The function
canonical_record() is public so that an
auditor can call it.
The anchor
Each outcome has one content hash, contentHash, with an anchor of three
parts (SEG-SREQ-190): the digest of the run bundle, written as
sha256: and 64 hex digits, as the repository member; the path of the run
artifact within the bundle, twister.json; and the locator nodeid:
followed by the result’s test identifier, for example
nodeid:safe_data.api.safe_data.init_and_verify. The bundle can lie anywhere:
a copy in another directory under another name gives the same anchors, and a
copy with one added file has another digest and so another repository member.
The path never depends on where the bundle is.
The edges
Each outcome has one Confirms edge to the test-case need its result maps to
(SEG-SREQ-188). It has a Witnesses edge to every implementation need that
satisfies a requirement that the test-case need verifies (SEG-SREQ-189). A
skipped outcome has the same edges as a passed one. All edges are pending. A
target that an export does not hold is emitted all the same, and the graph
reports it as a broken edge, and graph status exits with status 1 for it.
On the evidence fixture the four scenarios give 76
outcomes and 152 edges.
Errors
OutcomeError is raised when the
extractor is built, and never later. The extractor reads every input and maps
every result there, so the records it supplies are never a short stream. The
message names the bundle and, for one result, the result. The extractor
refuses:
a path that is not a directory, or a bundle that holds a link or an unreadable file (SEG-SREQ-224, SEG-SREQ-220);
a dirty implementation checkout, or a missing dirty flag of it (SEG-SREQ-222);
a revision record that is missing, empty or holds only a line feed (SEG-SREQ-187), or that holds two lines or has blanks around its text;
a name record that is missing, empty, has two lines or holds a slash (SEG-SREQ-223);
an artifact that cannot be read, is not a JSON object, has no
testsuiteslist or holds a suite or a result without the fields it needs (SEG-SREQ-224);an export that cannot be read, holds no or several versions or carries a build timestamp (SEG-SREQ-225);
a result with a status outside the table, named all at once with the count first (see “The status”);
a result that maps to no need or to more than one need. One error names every such result of an artifact (SEG-SREQ-354). The first line gives the count. Each next line holds one result, its board and its scenario, in the order of the artifact, so the same artifact always gives the same text. The same unmapped test on two boards gives two lines;
a misshapen need in the test-case export or in the implementation export, named all at once for each export in the order of the export (SEG-SREQ-353);
two results, in one run or in two, that give the same outcome identity. The message names both bundles.
A failure in one run stops the whole extractor. The operator corrects the input and builds it again. A bundle that fails gives its errors at once, and the extractor does not read the next bundle.
How the command line composes it
The command line chains the requirements reader, the content extractor and
this extractor into the current stream, in that order, but it builds this
extractor only for graph status, proof check and proof generate,
and only when the operator names bundles with the option --bundle PATH
(SEG-SREQ-229). The option may be given more than once. A relative path
is taken from the working directory. With no --bundle, the stream holds no
test evidence. The same directory named twice, also through a link, is read
once (SEG-SREQ-232); two copies of one bundle give the same outcome
identities and are refused.
case sync, case check, graph check, node show, edge show and
edge affirm take no --bundle (SEG-SREQ-230): the option is an argument
error for them, with exit status 2, and they never open a bundle. A bundle that
the extractor refuses ends the verb with exit status 2 and no verdict
(SEG-SREQ-231). Naming bundles together with --current is refused
with exit status 2, because a given stream holds its own evidence
(SEG-SREQ-233). Naming bundles with no test-case export in the
configuration, or with a store at producer.root, is refused with exit status
2 (SEG-SREQ-235): the extractor needs producer.specifications. The
implementation export is optional; without it, no Witnesses edge is supplied.
This extractor supplies no content behind its hashes. Its hash covers a record
that it builds from a run bundle, so it does not meet the protocol
ContentSource (SEG-SREQ-311).
The configuration names no run. A producer block that holds the key
outcomes is refused when the file is read, whatever the key holds
(SEG-SREQ-234).
A proof records the digest of each bundle that supplied an outcome in its scope (see The evidence package), so a reader can fetch the same bundles and build the same evidence again. The extractor keeps, for each outcome, the digest of the bundle that supplied it, for that purpose.