mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(confusion-matrix): score the checker against a blind reviewer
Cross-tabulates the properties that fired against the human verdict, one cell per implementation, over a sweep whose implementations all passed their own generated tests. An implementation that failed to build, has no usable run, or carries no filed verdict is listed as missing data rather than counted as a clean cell. Landing the package in one commit because the intermediate splits would not link.
This commit is contained in:
1 parent
8c8e87761d
commit
56a002d87a
8 files changed
+1722
No files matched your search
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The three models the sample is drawn from, in the capability order
|
||||
// model-implementations.md fixed before any implementation was generated.
|
||||
var capabilityOrder = []string{"Sonnet 5", "Opus 5", "Fable 5"}
|
||||
|
||||
var (
|
||||
implementationName = regexp.MustCompile(`^impl-\d+$`)
|
||||
nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]`)
|
||||
)
|
||||
|
||||
func loadAssignment(path string) (map[string]string, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read assignment: %w", err)
|
||||
}
|
||||
assignments := map[string]string{}
|
||||
for _, row := range parseTableRows(string(body)) {
|
||||
name := row.cell(0)
|
||||
if !implementationName.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
model, ok := canonicalModel(row.cell(1))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s line %d: %s is assigned model %q, which is none of %s",
|
||||
path, row.Line, name, row.cell(1), strings.Join(capabilityOrder, ", "))
|
||||
}
|
||||
if existing, seen := assignments[name]; seen && existing != model {
|
||||
return nil, fmt.Errorf("%s line %d: %s is assigned to both %s and %s",
|
||||
path, row.Line, name, existing, model)
|
||||
}
|
||||
assignments[name] = model
|
||||
}
|
||||
if len(assignments) == 0 {
|
||||
return nil, fmt.Errorf("%s maps no implementation to a model", path)
|
||||
}
|
||||
return assignments, nil
|
||||
}
|
||||
|
||||
func canonicalModel(value string) (string, bool) {
|
||||
key := nonAlphanumeric.ReplaceAllString(strings.ToLower(value), "")
|
||||
for _, model := range capabilityOrder {
|
||||
if key == nonAlphanumeric.ReplaceAllString(strings.ToLower(model), "") {
|
||||
return model, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
sweepManifestFileName = "sweep.json"
|
||||
sweepRecordsFileName = "implementations.jsonl"
|
||||
campaignManifestFileName = "campaign.json"
|
||||
campaignRecordsFileName = "runs.jsonl"
|
||||
traceFileName = "trace.jsonl"
|
||||
surfacesExtractor = "locatableSurfaces"
|
||||
maxRecordBytes = 4 * 1024 * 1024
|
||||
maxTraceLineBytes = 16 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Run exclusion reasons, kept in the vocabulary analyze already uses so the two
|
||||
// tools describe the same run the same way.
|
||||
const (
|
||||
reasonLaunchError = "launch error"
|
||||
reasonTimedOut = "timed out"
|
||||
reasonNonzeroExit = "nonzero exit"
|
||||
reasonTraceError = "unreadable trace"
|
||||
)
|
||||
|
||||
type sweepManifest struct {
|
||||
SpecPath string `json:"spec_path"`
|
||||
Implementations []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"implementations"`
|
||||
}
|
||||
|
||||
type sweepRunRecord struct {
|
||||
Seed int64 `json:"seed"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
LaunchError string `json:"launch_error"`
|
||||
CampaignDirectory string `json:"campaign_directory"`
|
||||
}
|
||||
|
||||
type sweepImplementationRecord struct {
|
||||
Name string `json:"implementation"`
|
||||
FailedStage string `json:"failed_stage"`
|
||||
Error string `json:"error"`
|
||||
Runs []sweepRunRecord `json:"runs"`
|
||||
}
|
||||
|
||||
type campaignRunRecord struct {
|
||||
Seed int64 `json:"seed"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
LaunchError string `json:"launch_error"`
|
||||
TimedOut bool `json:"timed_out"`
|
||||
TraceError string `json:"trace_error"`
|
||||
RunDirectory string `json:"run_directory"`
|
||||
ViolatedProperties []string `json:"violated_properties"`
|
||||
}
|
||||
|
||||
// checkerVerdict is one implementation's whole checker side, pooled across the
|
||||
// seeds it was swept at.
|
||||
type checkerVerdict struct {
|
||||
Implementation string
|
||||
FailedStage string
|
||||
FailedError string
|
||||
RunsRecorded int
|
||||
RunsUsable int
|
||||
ExcludedByReason map[string]int
|
||||
FiredProperties []string
|
||||
// SurfacesObserved holds every locatable surface seen true on at least one
|
||||
// step of at least one usable run. A surface missing from it was never
|
||||
// located across the whole sweep of this implementation.
|
||||
SurfacesObserved map[string]bool
|
||||
SurfacesKnown bool
|
||||
TraceErrors []string
|
||||
}
|
||||
|
||||
func (v checkerVerdict) fired() bool { return len(v.FiredProperties) > 0 }
|
||||
|
||||
type checkerSide struct {
|
||||
Directory string
|
||||
SpecPath string
|
||||
Planned []string
|
||||
Verdicts []checkerVerdict
|
||||
}
|
||||
|
||||
func loadChecker(directory string) (checkerSide, error) {
|
||||
body, err := os.ReadFile(filepath.Join(directory, sweepManifestFileName))
|
||||
if err != nil {
|
||||
return checkerSide{}, fmt.Errorf("read %s: %w", sweepManifestFileName, err)
|
||||
}
|
||||
var declared sweepManifest
|
||||
if err := json.Unmarshal(body, &declared); err != nil {
|
||||
return checkerSide{}, fmt.Errorf("parse %s in %s: %w", sweepManifestFileName, directory, err)
|
||||
}
|
||||
side := checkerSide{Directory: directory, SpecPath: declared.SpecPath}
|
||||
for _, planned := range declared.Implementations {
|
||||
side.Planned = append(side.Planned, planned.Name)
|
||||
}
|
||||
|
||||
records, err := readSweepRecords(filepath.Join(directory, sweepRecordsFileName))
|
||||
if err != nil {
|
||||
return checkerSide{}, err
|
||||
}
|
||||
for _, record := range records {
|
||||
verdict, err := readImplementation(directory, record)
|
||||
if err != nil {
|
||||
return checkerSide{}, err
|
||||
}
|
||||
side.Verdicts = append(side.Verdicts, verdict)
|
||||
}
|
||||
slices.SortFunc(side.Verdicts, func(a, b checkerVerdict) int {
|
||||
return strings.Compare(a.Implementation, b.Implementation)
|
||||
})
|
||||
return side, nil
|
||||
}
|
||||
|
||||
func readSweepRecords(path string) ([]sweepImplementationRecord, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", sweepRecordsFileName, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var records []sweepImplementationRecord
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxRecordBytes)
|
||||
lineNumber := 0
|
||||
seen := map[string]bool{}
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
raw := strings.TrimSpace(scanner.Text())
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
var record sweepImplementationRecord
|
||||
if err := json.Unmarshal([]byte(raw), &record); err != nil {
|
||||
return nil, fmt.Errorf("%s line %d: %w", sweepRecordsFileName, lineNumber, err)
|
||||
}
|
||||
if record.Name == "" {
|
||||
return nil, fmt.Errorf("%s line %d names no implementation", sweepRecordsFileName, lineNumber)
|
||||
}
|
||||
if seen[record.Name] {
|
||||
return nil, fmt.Errorf("%s line %d records %s a second time: its runs would be pooled twice",
|
||||
sweepRecordsFileName, lineNumber, record.Name)
|
||||
}
|
||||
seen[record.Name] = true
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", sweepRecordsFileName, err)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func readImplementation(sweepDirectory string, record sweepImplementationRecord) (checkerVerdict, error) {
|
||||
verdict := checkerVerdict{
|
||||
Implementation: record.Name,
|
||||
FailedStage: record.FailedStage,
|
||||
FailedError: record.Error,
|
||||
ExcludedByReason: map[string]int{},
|
||||
SurfacesObserved: map[string]bool{},
|
||||
}
|
||||
fired := map[string]bool{}
|
||||
for _, run := range record.Runs {
|
||||
verdict.RunsRecorded++
|
||||
if run.LaunchError != "" {
|
||||
verdict.ExcludedByReason[reasonLaunchError]++
|
||||
continue
|
||||
}
|
||||
directory := resolveCampaignDirectory(sweepDirectory, record.Name, run)
|
||||
campaignRuns, err := readCampaignRuns(directory)
|
||||
if err != nil {
|
||||
return checkerVerdict{}, err
|
||||
}
|
||||
for _, campaignRun := range campaignRuns {
|
||||
if reason := excludedBecause(campaignRun); reason != "" {
|
||||
verdict.ExcludedByReason[reason]++
|
||||
continue
|
||||
}
|
||||
verdict.RunsUsable++
|
||||
for _, property := range campaignRun.ViolatedProperties {
|
||||
fired[property] = true
|
||||
}
|
||||
observed, err := readObservedSurfaces(filepath.Join(directory, campaignRun.RunDirectory))
|
||||
if err != nil {
|
||||
verdict.TraceErrors = append(verdict.TraceErrors, err.Error())
|
||||
continue
|
||||
}
|
||||
verdict.SurfacesKnown = true
|
||||
for surface, seen := range observed {
|
||||
if seen {
|
||||
verdict.SurfacesObserved[surface] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(fired) > 0 {
|
||||
verdict.FiredProperties = slices.Sorted(maps.Keys(fired))
|
||||
}
|
||||
if len(verdict.ExcludedByReason) == 0 {
|
||||
verdict.ExcludedByReason = nil
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// resolveCampaignDirectory prefers the path the sweep recorded and falls back to
|
||||
// the layout it names, so a sweep directory read on another machine than the one
|
||||
// that wrote its absolute paths still resolves.
|
||||
func resolveCampaignDirectory(sweepDirectory, name string, run sweepRunRecord) string {
|
||||
if run.CampaignDirectory != "" {
|
||||
if _, err := os.Stat(run.CampaignDirectory); err == nil {
|
||||
return run.CampaignDirectory
|
||||
}
|
||||
}
|
||||
return filepath.Join(sweepDirectory, name, "seed-"+strconv.FormatInt(run.Seed, 10))
|
||||
}
|
||||
|
||||
func readCampaignRuns(directory string) ([]campaignRunRecord, error) {
|
||||
if _, err := os.Stat(filepath.Join(directory, campaignManifestFileName)); err != nil {
|
||||
return nil, fmt.Errorf("read %s in %s: %w", campaignManifestFileName, directory, err)
|
||||
}
|
||||
file, err := os.Open(filepath.Join(directory, campaignRecordsFileName))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", campaignRecordsFileName, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var records []campaignRunRecord
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxRecordBytes)
|
||||
lineNumber := 0
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
raw := strings.TrimSpace(scanner.Text())
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
var record campaignRunRecord
|
||||
if err := json.Unmarshal([]byte(raw), &record); err != nil {
|
||||
return nil, fmt.Errorf("%s line %d in %s: %w", campaignRecordsFileName, lineNumber, directory, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read %s in %s: %w", campaignRecordsFileName, directory, err)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func excludedBecause(record campaignRunRecord) string {
|
||||
switch {
|
||||
case record.LaunchError != "":
|
||||
return reasonLaunchError
|
||||
case record.TimedOut:
|
||||
return reasonTimedOut
|
||||
case record.ExitCode != 0:
|
||||
return reasonNonzeroExit
|
||||
case record.TraceError != "":
|
||||
return reasonTraceError
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type traceLine struct {
|
||||
ExtractorChanges map[string]struct {
|
||||
Curr json.RawMessage `json:"curr"`
|
||||
} `json:"extractor_changes"`
|
||||
}
|
||||
|
||||
// readObservedSurfaces replays one run's locatableSurfaces readings. The
|
||||
// verifier emits every extractor as a change on the first snapshot and only on
|
||||
// a difference afterwards, so a surface true on any recorded change was located
|
||||
// at least once, and one absent from every change was never located at all.
|
||||
func readObservedSurfaces(runDirectory string) (map[string]bool, error) {
|
||||
path := filepath.Join(runDirectory, traceFileName)
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
observed := map[string]bool{}
|
||||
found := false
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), maxTraceLineBytes)
|
||||
lineNumber := 0
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
raw := strings.TrimSpace(scanner.Text())
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
var line traceLine
|
||||
if err := json.Unmarshal([]byte(raw), &line); err != nil {
|
||||
return nil, fmt.Errorf("%s line %d: %w", path, lineNumber, err)
|
||||
}
|
||||
change, present := line.ExtractorChanges[surfacesExtractor]
|
||||
if !present || len(change.Curr) == 0 {
|
||||
continue
|
||||
}
|
||||
var reading map[string]bool
|
||||
if err := json.Unmarshal(change.Curr, &reading); err != nil {
|
||||
return nil, fmt.Errorf("%s line %d: %s is not an object of booleans: %w",
|
||||
path, lineNumber, surfacesExtractor, err)
|
||||
}
|
||||
found = true
|
||||
for surface, located := range reading {
|
||||
if located {
|
||||
observed[surface] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%s records no %s reading: this run cannot say whether a surface was located",
|
||||
path, surfacesExtractor)
|
||||
}
|
||||
return observed, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Command confusion-matrix cross-tabulates e4's checker verdicts against the
|
||||
// blind human review, which is the measure model-implementations.md
|
||||
// pre-registers: an implementation whose own suite passed, scored on whether a
|
||||
// property fired and on whether the reviewer found a defect.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const usage = `confusion-matrix cross-tabulates the e4 checker against the blind human review.
|
||||
|
||||
Usage:
|
||||
confusion-matrix --sweep <dir> --reviews <dir> --assignment <path> --property-clauses <path> [--json <path>]
|
||||
|
||||
--sweep is the directory implementation-sweep wrote: sweep.json, implementations.jsonl
|
||||
and the per-implementation campaign directories under it.
|
||||
|
||||
--reviews holds one impl-NN.md per implementation in the shape review-protocol.md
|
||||
fixes, plus any impl-NN-adjudication.md whose resolved labels replace the first
|
||||
rater's for the clauses it names.
|
||||
|
||||
--assignment is implementations/assignment.md, the blinded implementation-to-model
|
||||
mapping, opened only after the last verdict is filed.
|
||||
|
||||
--property-clauses declares which requirement clauses each property covers and which
|
||||
locatable surfaces it reads. Without it a fired property cannot be scored against a
|
||||
clause and a portability miss cannot be told from a clean run.
|
||||
`
|
||||
|
||||
func run(arguments []string, stdout, stderr io.Writer) error {
|
||||
flagSet := flag.NewFlagSet("confusion-matrix", flag.ContinueOnError)
|
||||
flagSet.SetOutput(stderr)
|
||||
flagSet.Usage = func() {
|
||||
fmt.Fprint(stderr, usage)
|
||||
flagSet.PrintDefaults()
|
||||
}
|
||||
var sweepDirectory string
|
||||
var reviewsDirectory string
|
||||
var assignmentPath string
|
||||
var mappingPath string
|
||||
var jsonPath string
|
||||
flagSet.StringVar(&sweepDirectory, "sweep", "", "directory implementation-sweep wrote")
|
||||
flagSet.StringVar(&reviewsDirectory, "reviews", "", "directory holding impl-NN.md verdict forms")
|
||||
flagSet.StringVar(&assignmentPath, "assignment", "", "implementations/assignment.md, the implementation-to-model mapping")
|
||||
flagSet.StringVar(&mappingPath, "property-clauses", "", "the declared property-to-clause and surface mapping")
|
||||
flagSet.StringVar(&jsonPath, "json", "", "write the machine-readable summary here, or - for stdout")
|
||||
if err := flagSet.Parse(arguments); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"--sweep": sweepDirectory,
|
||||
"--reviews": reviewsDirectory,
|
||||
"--assignment": assignmentPath,
|
||||
"--property-clauses": mappingPath,
|
||||
} {
|
||||
if value == "" {
|
||||
return fmt.Errorf("%s is required", name)
|
||||
}
|
||||
}
|
||||
|
||||
mapping, err := loadMapping(mappingPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assignments, err := loadAssignment(assignmentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
checker, err := loadChecker(sweepDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reviews, err := loadReviews(reviewsDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := crossTabulate(checker, reviews, assignments, mapping, time.Now().UTC())
|
||||
writeReport(result, stdout)
|
||||
|
||||
if jsonPath == "" {
|
||||
return nil
|
||||
}
|
||||
body, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal summary: %w", err)
|
||||
}
|
||||
body = append(body, '\n')
|
||||
if jsonPath == "-" {
|
||||
_, err = stdout.Write(body)
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(jsonPath, body, 0o644)
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// clauseCount is R1 to R20, the twenty clauses requirement.md numbers and the
|
||||
// twenty rows review-protocol.md requires on every verdict form.
|
||||
const clauseCount = 20
|
||||
|
||||
const (
|
||||
surfaceUnlocatable = "unlocatable"
|
||||
surfaceInconclusive = "inconclusive"
|
||||
)
|
||||
|
||||
const todoMarker = "todo"
|
||||
|
||||
// propertyMapping is one row of the property table: the clauses a property is
|
||||
// the oracle for, and the locatable surfaces it reads.
|
||||
type propertyMapping struct {
|
||||
Property string
|
||||
Clauses []string
|
||||
Surfaces []string
|
||||
}
|
||||
|
||||
// surfaceMapping says what a surface never observed across an implementation's
|
||||
// whole sweep means. Only a surface the requirement obliges every
|
||||
// implementation to show at all times can be read as unlocatable; a surface
|
||||
// that is legitimately absent when nothing is in that state is inconclusive
|
||||
// and can never mark a property unevaluated.
|
||||
type surfaceMapping struct {
|
||||
Surface string
|
||||
NeverObserved string
|
||||
Note string
|
||||
}
|
||||
|
||||
type mapping struct {
|
||||
Path string
|
||||
Properties []propertyMapping
|
||||
Surfaces []surfaceMapping
|
||||
PropertyTodoRows int
|
||||
byProperty map[string]propertyMapping
|
||||
coveringProperty map[string][]string
|
||||
unlocatableSurface map[string]bool
|
||||
}
|
||||
|
||||
var clausePattern = regexp.MustCompile(`^[Rr]([0-9]{1,2})$`)
|
||||
|
||||
func canonicalClause(value string) (string, bool) {
|
||||
match := clausePattern.FindStringSubmatch(strings.TrimSpace(value))
|
||||
if match == nil {
|
||||
return "", false
|
||||
}
|
||||
number := match[1]
|
||||
trimmed := strings.TrimLeft(number, "0")
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
clause := "R" + trimmed
|
||||
if !slices.Contains(allClauses(), clause) {
|
||||
return "", false
|
||||
}
|
||||
return clause, true
|
||||
}
|
||||
|
||||
func allClauses() []string {
|
||||
clauses := make([]string, 0, clauseCount)
|
||||
for index := 1; index <= clauseCount; index++ {
|
||||
clauses = append(clauses, fmt.Sprintf("R%d", index))
|
||||
}
|
||||
return clauses
|
||||
}
|
||||
|
||||
func loadMapping(path string) (mapping, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return mapping{}, fmt.Errorf("read property-clause mapping: %w", err)
|
||||
}
|
||||
result := mapping{
|
||||
Path: path,
|
||||
byProperty: map[string]propertyMapping{},
|
||||
coveringProperty: map[string][]string{},
|
||||
unlocatableSurface: map[string]bool{},
|
||||
}
|
||||
section := ""
|
||||
for _, row := range parseTableRows(string(body)) {
|
||||
head := strings.ToLower(row.cell(0))
|
||||
switch head {
|
||||
case "property":
|
||||
section = "property"
|
||||
continue
|
||||
case "surface":
|
||||
section = "surface"
|
||||
continue
|
||||
}
|
||||
switch section {
|
||||
case "property":
|
||||
if err := result.addProperty(row); err != nil {
|
||||
return mapping{}, fmt.Errorf("%s line %d: %w", path, row.Line, err)
|
||||
}
|
||||
case "surface":
|
||||
if err := result.addSurface(row); err != nil {
|
||||
return mapping{}, fmt.Errorf("%s line %d: %w", path, row.Line, err)
|
||||
}
|
||||
default:
|
||||
return mapping{}, fmt.Errorf("%s line %d: table row before any header naming property or surface", path, row.Line)
|
||||
}
|
||||
}
|
||||
if len(result.Surfaces) == 0 {
|
||||
return mapping{}, fmt.Errorf("%s declares no surfaces: a portability miss cannot be told from a clean run without them", path)
|
||||
}
|
||||
for _, property := range result.Properties {
|
||||
for _, surface := range property.Surfaces {
|
||||
if _, declared := result.surfaceByName(surface); !declared {
|
||||
return mapping{}, fmt.Errorf("%s: property %q reads surface %q, which the surface table does not declare",
|
||||
path, property.Property, surface)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mapping) addProperty(row tableRow) error {
|
||||
name := row.cell(0)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(name, todoMarker) {
|
||||
m.PropertyTodoRows++
|
||||
return nil
|
||||
}
|
||||
if _, seen := m.byProperty[name]; seen {
|
||||
return fmt.Errorf("property %q is mapped twice", name)
|
||||
}
|
||||
entry := propertyMapping{Property: name}
|
||||
for _, item := range splitList(row.cell(1)) {
|
||||
if strings.EqualFold(item, todoMarker) || item == "-" || strings.EqualFold(item, "none") {
|
||||
continue
|
||||
}
|
||||
clause, ok := canonicalClause(item)
|
||||
if !ok {
|
||||
return fmt.Errorf("property %q names clause %q, which is not one of R1 to R%d", name, item, clauseCount)
|
||||
}
|
||||
if slices.Contains(entry.Clauses, clause) {
|
||||
continue
|
||||
}
|
||||
entry.Clauses = append(entry.Clauses, clause)
|
||||
}
|
||||
for _, item := range splitList(row.cell(2)) {
|
||||
if strings.EqualFold(item, todoMarker) || item == "-" || strings.EqualFold(item, "none") {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(entry.Surfaces, item) {
|
||||
entry.Surfaces = append(entry.Surfaces, item)
|
||||
}
|
||||
}
|
||||
m.Properties = append(m.Properties, entry)
|
||||
m.byProperty[name] = entry
|
||||
for _, clause := range entry.Clauses {
|
||||
m.coveringProperty[clause] = append(m.coveringProperty[clause], name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mapping) addSurface(row tableRow) error {
|
||||
name := row.cell(0)
|
||||
if name == "" || strings.EqualFold(name, todoMarker) {
|
||||
return nil
|
||||
}
|
||||
meaning := strings.ToLower(row.cell(1))
|
||||
if meaning != surfaceUnlocatable && meaning != surfaceInconclusive {
|
||||
return fmt.Errorf("surface %q says %q for never observed, want %s or %s",
|
||||
name, row.cell(1), surfaceUnlocatable, surfaceInconclusive)
|
||||
}
|
||||
if _, seen := m.surfaceByName(name); seen {
|
||||
return fmt.Errorf("surface %q is declared twice", name)
|
||||
}
|
||||
m.Surfaces = append(m.Surfaces, surfaceMapping{Surface: name, NeverObserved: meaning, Note: row.cell(2)})
|
||||
if meaning == surfaceUnlocatable {
|
||||
m.unlocatableSurface[name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m mapping) surfaceByName(name string) (surfaceMapping, bool) {
|
||||
for _, surface := range m.Surfaces {
|
||||
if surface.Surface == name {
|
||||
return surface, true
|
||||
}
|
||||
}
|
||||
return surfaceMapping{}, false
|
||||
}
|
||||
|
||||
// unevaluable reports whether a property could not be evaluated against this
|
||||
// implementation because a surface it reads was never located. A surface the
|
||||
// mapping calls inconclusive never makes a property unevaluable, however many
|
||||
// steps failed to observe it.
|
||||
func (m mapping) unevaluable(property string, observed map[string]bool) bool {
|
||||
entry, known := m.byProperty[property]
|
||||
if !known {
|
||||
return false
|
||||
}
|
||||
for _, surface := range entry.Surfaces {
|
||||
if m.unlocatableSurface[surface] && !observed[surface] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m mapping) covering(clause string) []string {
|
||||
return m.coveringProperty[clause]
|
||||
}
|
||||
|
||||
func (m mapping) knows(property string) bool {
|
||||
_, known := m.byProperty[property]
|
||||
return known
|
||||
}
|
||||
|
||||
func (m mapping) unlocatableSurfaces() []string {
|
||||
var names []string
|
||||
for _, surface := range m.Surfaces {
|
||||
if surface.NeverObserved == surfaceUnlocatable {
|
||||
names = append(names, surface.Surface)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The four cells of the pre-registered measure, named as
|
||||
// model-implementations.md describes them.
|
||||
const (
|
||||
cellTruePositive = "checker fired, review confirmed"
|
||||
cellFalsePositive = "checker fired, review found nothing"
|
||||
cellFalseNegative = "checker silent, review found a defect"
|
||||
cellTrueNegative = "checker silent, review found nothing"
|
||||
)
|
||||
|
||||
// Reasons an implementation is missing data rather than a cell of the matrix.
|
||||
const (
|
||||
missingSweepStage = "sweep stopped before any run"
|
||||
missingNoUsableRun = "no usable run"
|
||||
missingNoSweepRecord = "no sweep record"
|
||||
missingNoReview = "no verdict filed"
|
||||
missingMalformed = "malformed verdict form"
|
||||
missingSurfaces = "surface locatability unknown"
|
||||
missingNoModel = "not in the assignment mapping"
|
||||
)
|
||||
|
||||
type matrix struct {
|
||||
Unit string `json:"unit"`
|
||||
Scored int `json:"scored"`
|
||||
TruePositive int `json:"true_positive"`
|
||||
FalsePositive int `json:"false_positive"`
|
||||
FalseNegative int `json:"false_negative"`
|
||||
TrueNegative int `json:"true_negative"`
|
||||
Precision *float64 `json:"precision"`
|
||||
Recall *float64 `json:"recall"`
|
||||
}
|
||||
|
||||
func (m *matrix) add(checkerPositive, humanPositive bool) {
|
||||
m.Scored++
|
||||
switch {
|
||||
case checkerPositive && humanPositive:
|
||||
m.TruePositive++
|
||||
case checkerPositive:
|
||||
m.FalsePositive++
|
||||
case humanPositive:
|
||||
m.FalseNegative++
|
||||
default:
|
||||
m.TrueNegative++
|
||||
}
|
||||
}
|
||||
|
||||
func (m *matrix) finish() {
|
||||
m.Precision = ratio(m.TruePositive, m.TruePositive+m.FalsePositive)
|
||||
m.Recall = ratio(m.TruePositive, m.TruePositive+m.FalseNegative)
|
||||
}
|
||||
|
||||
func ratio(numerator, denominator int) *float64 {
|
||||
if denominator == 0 {
|
||||
return nil
|
||||
}
|
||||
value := float64(numerator) / float64(denominator)
|
||||
return &value
|
||||
}
|
||||
|
||||
// clauseMatrix scores one implementation-clause pair. Its three side buckets
|
||||
// hold the pairs that are not evidence either way: a clause the reviewer could
|
||||
// not judge, a clause whose every covering property was unevaluable because a
|
||||
// surface was never located, and, tracked but still scored, a clause no
|
||||
// property covers at all.
|
||||
type clauseMatrix struct {
|
||||
matrix
|
||||
CannotTell int `json:"cannot_tell"`
|
||||
UnevaluatedSurfaceMissed int `json:"unevaluated_surface_missed"`
|
||||
UncoveredScored int `json:"uncovered_clause_pairs_scored"`
|
||||
UncoveredFalseNegative int `json:"uncovered_clause_false_negatives"`
|
||||
}
|
||||
|
||||
type implementationOutcome struct {
|
||||
Implementation string `json:"implementation"`
|
||||
Model string `json:"model"`
|
||||
Cell string `json:"cell"`
|
||||
FiredProperties []string `json:"fired_properties,omitempty"`
|
||||
ViolatedClauses []string `json:"violated_clauses,omitempty"`
|
||||
CannotTellClauses []string `json:"cannot_tell_clauses,omitempty"`
|
||||
UnlocatableSurfaces []string `json:"unlocatable_surfaces,omitempty"`
|
||||
UnevaluatedProperties []string `json:"unevaluated_properties,omitempty"`
|
||||
// DefectOnlyOnUnevaluatedClauses marks a false negative the checker was
|
||||
// never in a position to catch: every clause the reviewer faulted is
|
||||
// covered only by properties a missing surface left unevaluable. It is a
|
||||
// portability miss reported beside the matrix, never inside it.
|
||||
DefectOnlyOnUnevaluatedClauses bool `json:"defect_only_on_unevaluated_clauses,omitempty"`
|
||||
RunsUsable int `json:"runs_usable"`
|
||||
ReviewMinutes int `json:"review_minutes,omitempty"`
|
||||
}
|
||||
|
||||
type exclusion struct {
|
||||
Implementation string `json:"implementation"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type modelBreakdown struct {
|
||||
Model string `json:"model"`
|
||||
Implementations matrix `json:"implementation_matrix"`
|
||||
Clauses clauseMatrix `json:"clause_matrix"`
|
||||
Excluded int `json:"excluded"`
|
||||
DefectOnlyOnUnevaluatedClauses int `json:"defect_only_on_unevaluated_clauses"`
|
||||
}
|
||||
|
||||
type portability struct {
|
||||
Scored int `json:"implementations_scored"`
|
||||
WithUnlocatableSurface int `json:"implementations_with_an_unlocatable_surface"`
|
||||
BySurface map[string]int `json:"implementations_by_unlocatable_surface,omitempty"`
|
||||
SurfacesReadAsInconclusive []string `json:"surfaces_a_miss_cannot_be_read_from,omitempty"`
|
||||
}
|
||||
|
||||
type coverage struct {
|
||||
MappedProperties int `json:"mapped_properties"`
|
||||
MappingTodoRows int `json:"mapping_todo_rows"`
|
||||
ClausesCovered []string `json:"clauses_covered,omitempty"`
|
||||
ClausesUncovered []string `json:"clauses_no_property_covers,omitempty"`
|
||||
FiredPropertiesNotMapped []string `json:"fired_properties_not_in_the_mapping,omitempty"`
|
||||
}
|
||||
|
||||
type result struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
SweepDirectory string `json:"sweep_directory"`
|
||||
ReviewsDirectory string `json:"reviews_directory"`
|
||||
MappingPath string `json:"property_clause_mapping"`
|
||||
SpecPath string `json:"spec_path,omitempty"`
|
||||
Implementations matrix `json:"implementation_matrix"`
|
||||
Clauses clauseMatrix `json:"clause_matrix"`
|
||||
ByModel []modelBreakdown `json:"by_model"`
|
||||
Outcomes []implementationOutcome `json:"outcomes"`
|
||||
Excluded []exclusion `json:"excluded,omitempty"`
|
||||
Portability portability `json:"portability"`
|
||||
ReviewMinutes int `json:"review_minutes_over_scored_implementations"`
|
||||
Coverage coverage `json:"clause_coverage"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
func crossTabulate(
|
||||
checker checkerSide,
|
||||
reviews reviewSide,
|
||||
assignments map[string]string,
|
||||
declared mapping,
|
||||
now time.Time,
|
||||
) result {
|
||||
outcome := result{
|
||||
GeneratedAt: now,
|
||||
SweepDirectory: checker.Directory,
|
||||
ReviewsDirectory: reviews.Directory,
|
||||
MappingPath: declared.Path,
|
||||
SpecPath: checker.SpecPath,
|
||||
Implementations: matrix{Unit: "implementation"},
|
||||
Clauses: clauseMatrix{matrix: matrix{Unit: "implementation-clause pair"}},
|
||||
Portability: portability{BySurface: map[string]int{}},
|
||||
}
|
||||
|
||||
reviewByName := map[string]reviewVerdict{}
|
||||
for _, verdict := range reviews.Verdicts {
|
||||
reviewByName[verdict.Implementation] = verdict
|
||||
}
|
||||
malformedByName := map[string]malformedReview{}
|
||||
for _, entry := range reviews.Malformed {
|
||||
malformedByName[entry.Implementation] = entry
|
||||
}
|
||||
checkerByName := map[string]checkerVerdict{}
|
||||
for _, verdict := range checker.Verdicts {
|
||||
checkerByName[verdict.Implementation] = verdict
|
||||
}
|
||||
|
||||
byModel := map[string]*modelBreakdown{}
|
||||
modelOf := func(name string) string { return assignments[name] }
|
||||
breakdown := func(model string) *modelBreakdown {
|
||||
current, seen := byModel[model]
|
||||
if !seen {
|
||||
current = &modelBreakdown{
|
||||
Model: model,
|
||||
Implementations: matrix{Unit: "implementation"},
|
||||
Clauses: clauseMatrix{matrix: matrix{Unit: "implementation-clause pair"}},
|
||||
}
|
||||
byModel[model] = current
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
unmappedFired := map[string]bool{}
|
||||
for _, name := range implementationNames(checker, reviews, assignments) {
|
||||
model := modelOf(name)
|
||||
verdict, swept := checkerByName[name]
|
||||
review, reviewed := reviewByName[name]
|
||||
|
||||
if reason, detail := missingData(name, model, verdict, swept, reviewed, malformedByName); reason != "" {
|
||||
outcome.Excluded = append(outcome.Excluded, exclusion{
|
||||
Implementation: name, Model: model, Reason: reason, Detail: detail,
|
||||
})
|
||||
if model != "" {
|
||||
breakdown(model).Excluded++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for _, property := range verdict.FiredProperties {
|
||||
if !declared.knows(property) {
|
||||
unmappedFired[property] = true
|
||||
}
|
||||
}
|
||||
row := scoreImplementation(verdict, review, declared)
|
||||
row.Model = model
|
||||
outcome.Outcomes = append(outcome.Outcomes, row)
|
||||
|
||||
modelRow := breakdown(model)
|
||||
outcome.Implementations.add(verdict.fired(), review.defective())
|
||||
modelRow.Implementations.add(verdict.fired(), review.defective())
|
||||
scoreClauses(&outcome.Clauses, &modelRow.Clauses, verdict, review, declared)
|
||||
|
||||
if row.DefectOnlyOnUnevaluatedClauses {
|
||||
modelRow.DefectOnlyOnUnevaluatedClauses++
|
||||
}
|
||||
outcome.Portability.Scored++
|
||||
outcome.ReviewMinutes += review.Minutes
|
||||
if len(row.UnlocatableSurfaces) > 0 {
|
||||
outcome.Portability.WithUnlocatableSurface++
|
||||
for _, surface := range row.UnlocatableSurfaces {
|
||||
outcome.Portability.BySurface[surface]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outcome.Implementations.finish()
|
||||
outcome.Clauses.finish()
|
||||
for _, model := range capabilityOrder {
|
||||
current, seen := byModel[model]
|
||||
if !seen {
|
||||
continue
|
||||
}
|
||||
current.Implementations.finish()
|
||||
current.Clauses.finish()
|
||||
outcome.ByModel = append(outcome.ByModel, *current)
|
||||
}
|
||||
|
||||
outcome.Coverage = describeCoverage(declared, unmappedFired)
|
||||
outcome.Portability.SurfacesReadAsInconclusive = inconclusiveSurfaces(declared)
|
||||
outcome.Notes = buildNotes(outcome, declared, reviews)
|
||||
return outcome
|
||||
}
|
||||
|
||||
func implementationNames(checker checkerSide, reviews reviewSide, assignments map[string]string) []string {
|
||||
names := map[string]bool{}
|
||||
for _, planned := range checker.Planned {
|
||||
names[planned] = true
|
||||
}
|
||||
for _, verdict := range checker.Verdicts {
|
||||
names[verdict.Implementation] = true
|
||||
}
|
||||
for _, verdict := range reviews.Verdicts {
|
||||
names[verdict.Implementation] = true
|
||||
}
|
||||
for _, entry := range reviews.Malformed {
|
||||
names[entry.Implementation] = true
|
||||
}
|
||||
for name := range assignments {
|
||||
names[name] = true
|
||||
}
|
||||
return slices.Sorted(maps.Keys(names))
|
||||
}
|
||||
|
||||
// missingData names why an implementation carries no cell. A build that never
|
||||
// finished, a run that never produced a usable campaign and a verdict that was
|
||||
// never filed are all absent evidence: scoring any of them as a clean run would
|
||||
// read the gap as agreement.
|
||||
func missingData(
|
||||
name string,
|
||||
model string,
|
||||
verdict checkerVerdict,
|
||||
swept bool,
|
||||
reviewed bool,
|
||||
malformed map[string]malformedReview,
|
||||
) (string, string) {
|
||||
if model == "" {
|
||||
return missingNoModel, ""
|
||||
}
|
||||
if !swept {
|
||||
return missingNoSweepRecord, ""
|
||||
}
|
||||
if verdict.FailedStage != "" {
|
||||
return missingSweepStage, fmt.Sprintf("%s: %s", verdict.FailedStage, verdict.FailedError)
|
||||
}
|
||||
if verdict.RunsUsable == 0 {
|
||||
return missingNoUsableRun, excludedSummary(verdict)
|
||||
}
|
||||
if entry, broken := malformed[name]; broken {
|
||||
return missingMalformed, entry.Reason
|
||||
}
|
||||
if !reviewed {
|
||||
return missingNoReview, ""
|
||||
}
|
||||
if !verdict.SurfacesKnown {
|
||||
return missingSurfaces, strings.Join(verdict.TraceErrors, "; ")
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func excludedSummary(verdict checkerVerdict) string {
|
||||
if len(verdict.ExcludedByReason) == 0 {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for _, reason := range slices.Sorted(maps.Keys(verdict.ExcludedByReason)) {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", reason, verdict.ExcludedByReason[reason]))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func scoreImplementation(verdict checkerVerdict, review reviewVerdict, declared mapping) implementationOutcome {
|
||||
row := implementationOutcome{
|
||||
Implementation: verdict.Implementation,
|
||||
Cell: cellOf(verdict.fired(), review.defective()),
|
||||
FiredProperties: verdict.FiredProperties,
|
||||
ViolatedClauses: review.violatedClauses(),
|
||||
RunsUsable: verdict.RunsUsable,
|
||||
ReviewMinutes: review.Minutes,
|
||||
}
|
||||
for _, clause := range allClauses() {
|
||||
if review.Clauses[clause] == clauseCannotTell {
|
||||
row.CannotTellClauses = append(row.CannotTellClauses, clause)
|
||||
}
|
||||
}
|
||||
for _, surface := range declared.unlocatableSurfaces() {
|
||||
if !verdict.SurfacesObserved[surface] {
|
||||
row.UnlocatableSurfaces = append(row.UnlocatableSurfaces, surface)
|
||||
}
|
||||
}
|
||||
for _, property := range declared.Properties {
|
||||
if declared.unevaluable(property.Property, verdict.SurfacesObserved) {
|
||||
row.UnevaluatedProperties = append(row.UnevaluatedProperties, property.Property)
|
||||
}
|
||||
}
|
||||
row.DefectOnlyOnUnevaluatedClauses = attributableToUnevaluated(verdict, review, declared)
|
||||
return row
|
||||
}
|
||||
|
||||
// attributableToUnevaluated reports a false negative the checker could not have
|
||||
// caught: it fired nothing, the reviewer faulted at least one clause, and every
|
||||
// clause the reviewer faulted is covered only by properties a missing surface
|
||||
// left unevaluable.
|
||||
func attributableToUnevaluated(verdict checkerVerdict, review reviewVerdict, declared mapping) bool {
|
||||
if verdict.fired() || !review.defective() {
|
||||
return false
|
||||
}
|
||||
violated := review.violatedClauses()
|
||||
if len(violated) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, clause := range violated {
|
||||
covering := declared.covering(clause)
|
||||
if len(covering) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, property := range covering {
|
||||
if !declared.unevaluable(property, verdict.SurfacesObserved) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cellOf(checkerPositive, humanPositive bool) string {
|
||||
switch {
|
||||
case checkerPositive && humanPositive:
|
||||
return cellTruePositive
|
||||
case checkerPositive:
|
||||
return cellFalsePositive
|
||||
case humanPositive:
|
||||
return cellFalseNegative
|
||||
default:
|
||||
return cellTrueNegative
|
||||
}
|
||||
}
|
||||
|
||||
func scoreClauses(overall, model *clauseMatrix, verdict checkerVerdict, review reviewVerdict, declared mapping) {
|
||||
fired := map[string]bool{}
|
||||
for _, property := range verdict.FiredProperties {
|
||||
fired[property] = true
|
||||
}
|
||||
for _, clause := range allClauses() {
|
||||
label := review.Clauses[clause]
|
||||
if label == clauseCannotTell {
|
||||
overall.CannotTell++
|
||||
model.CannotTell++
|
||||
continue
|
||||
}
|
||||
covering := declared.covering(clause)
|
||||
checkerPositive := false
|
||||
for _, property := range covering {
|
||||
if fired[property] {
|
||||
checkerPositive = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !checkerPositive && len(covering) > 0 && allUnevaluable(covering, verdict, declared) {
|
||||
overall.UnevaluatedSurfaceMissed++
|
||||
model.UnevaluatedSurfaceMissed++
|
||||
continue
|
||||
}
|
||||
humanPositive := label == clauseViolates
|
||||
overall.add(checkerPositive, humanPositive)
|
||||
model.add(checkerPositive, humanPositive)
|
||||
if len(covering) == 0 {
|
||||
overall.UncoveredScored++
|
||||
model.UncoveredScored++
|
||||
if humanPositive {
|
||||
overall.UncoveredFalseNegative++
|
||||
model.UncoveredFalseNegative++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func allUnevaluable(covering []string, verdict checkerVerdict, declared mapping) bool {
|
||||
for _, property := range covering {
|
||||
if !declared.unevaluable(property, verdict.SurfacesObserved) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func describeCoverage(declared mapping, unmappedFired map[string]bool) coverage {
|
||||
result := coverage{
|
||||
MappedProperties: len(declared.Properties),
|
||||
MappingTodoRows: declared.PropertyTodoRows,
|
||||
}
|
||||
for _, clause := range allClauses() {
|
||||
if len(declared.covering(clause)) > 0 {
|
||||
result.ClausesCovered = append(result.ClausesCovered, clause)
|
||||
continue
|
||||
}
|
||||
result.ClausesUncovered = append(result.ClausesUncovered, clause)
|
||||
}
|
||||
if len(unmappedFired) > 0 {
|
||||
result.FiredPropertiesNotMapped = slices.Sorted(maps.Keys(unmappedFired))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func inconclusiveSurfaces(declared mapping) []string {
|
||||
var names []string
|
||||
for _, surface := range declared.Surfaces {
|
||||
if surface.NeverObserved == surfaceInconclusive {
|
||||
names = append(names, surface.Surface)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func buildNotes(outcome result, declared mapping, reviews reviewSide) []string {
|
||||
var notes []string
|
||||
if len(declared.Properties) == 0 {
|
||||
notes = append(notes, fmt.Sprintf(
|
||||
"%s maps no property to a clause, so the clause matrix is empty and no portability miss can be detected; "+
|
||||
"the implementation matrix below stands on its own", declared.Path))
|
||||
}
|
||||
if declared.PropertyTodoRows > 0 {
|
||||
notes = append(notes, fmt.Sprintf("%s still carries %d TODO row(s) in its property table",
|
||||
declared.Path, declared.PropertyTodoRows))
|
||||
}
|
||||
if len(outcome.Coverage.FiredPropertiesNotMapped) > 0 {
|
||||
notes = append(notes, fmt.Sprintf(
|
||||
"%d fired propert(ies) are absent from the mapping and could not be attributed to a clause: %s",
|
||||
len(outcome.Coverage.FiredPropertiesNotMapped),
|
||||
strings.Join(outcome.Coverage.FiredPropertiesNotMapped, ", ")))
|
||||
}
|
||||
for _, verdict := range reviews.Verdicts {
|
||||
if !verdict.defective() && len(verdict.violatedClauses()) > 0 {
|
||||
notes = append(notes, fmt.Sprintf(
|
||||
"%s files %d violating clause(s) under an overall verdict of %s; the overall verdict is what the matrix scores",
|
||||
verdict.Implementation, len(verdict.violatedClauses()), overallNotDefective))
|
||||
}
|
||||
if len(verdict.Adjudicated) > 0 {
|
||||
notes = append(notes, fmt.Sprintf("%s uses the adjudicated label for %s",
|
||||
verdict.Implementation, strings.Join(verdict.Adjudicated, ", ")))
|
||||
}
|
||||
}
|
||||
if count := attributedFalseNegatives(outcome); count > 0 {
|
||||
notes = append(notes, fmt.Sprintf(
|
||||
"%d false negative(s) fault only clauses whose every property a missing surface left unevaluable: "+
|
||||
"those are portability misses, not blind spots", count))
|
||||
}
|
||||
notes = append(notes, "second-rater agreement and Cohen's kappa are not computed here; "+
|
||||
"an impl-NN-adjudication.md is read and its resolved labels replace the first rater's")
|
||||
return notes
|
||||
}
|
||||
|
||||
func attributedFalseNegatives(outcome result) int {
|
||||
count := 0
|
||||
for _, row := range outcome.Outcomes {
|
||||
if row.DefectOnlyOnUnevaluatedClauses {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// labelledMatrix is one row of a printed matrix: the whole sample, or one model.
|
||||
type labelledMatrix struct {
|
||||
Label string
|
||||
Value clauseMatrix
|
||||
Excluded int
|
||||
}
|
||||
|
||||
func writeReport(outcome result, out io.Writer) {
|
||||
fmt.Fprintln(out, "unit: implementation whose own suite passed, scored on whether a property fired and on the reviewer's overall verdict")
|
||||
fmt.Fprintln(out, "an implementation with no cell is missing data and is listed separately, never counted as a clean run")
|
||||
fmt.Fprintln(out)
|
||||
writeTable(out, []string{"group", "scored", "true pos", "false pos", "false neg", "true neg", "precision", "recall", "no cell"},
|
||||
func(add func(...string)) {
|
||||
for _, row := range implementationRows(outcome) {
|
||||
add(
|
||||
row.Label,
|
||||
strconv.Itoa(row.Value.Scored),
|
||||
strconv.Itoa(row.Value.TruePositive),
|
||||
strconv.Itoa(row.Value.FalsePositive),
|
||||
strconv.Itoa(row.Value.FalseNegative),
|
||||
strconv.Itoa(row.Value.TrueNegative),
|
||||
formatRatio(row.Value.Precision),
|
||||
formatRatio(row.Value.Recall),
|
||||
strconv.Itoa(row.Excluded),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "clause pairs are an implementation against one of R1 to R20, scored through the declared property-to-clause mapping")
|
||||
fmt.Fprintln(out, "cannot tell is the reviewer's specification-error answer and is evidence neither way")
|
||||
fmt.Fprintln(out, "unevaluated is a clause whose every covering property a never-located surface left unrunnable: a portability miss, not a cell")
|
||||
writeTable(out, []string{"group", "scored", "true pos", "false pos", "false neg", "true neg",
|
||||
"precision", "recall", "cannot tell", "unevaluated", "uncovered", "uncovered false neg"},
|
||||
func(add func(...string)) {
|
||||
for _, row := range clauseRows(outcome) {
|
||||
add(
|
||||
row.Label,
|
||||
strconv.Itoa(row.Value.Scored),
|
||||
strconv.Itoa(row.Value.TruePositive),
|
||||
strconv.Itoa(row.Value.FalsePositive),
|
||||
strconv.Itoa(row.Value.FalseNegative),
|
||||
strconv.Itoa(row.Value.TrueNegative),
|
||||
formatRatio(row.Value.Precision),
|
||||
formatRatio(row.Value.Recall),
|
||||
strconv.Itoa(row.Value.CannotTell),
|
||||
strconv.Itoa(row.Value.UnevaluatedSurfaceMissed),
|
||||
strconv.Itoa(row.Value.UncoveredScored),
|
||||
strconv.Itoa(row.Value.UncoveredFalseNegative),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
fmt.Fprintln(out)
|
||||
writeTable(out, []string{"implementation", "model", "cell", "usable runs", "fired", "violated clauses", "cannot tell"},
|
||||
func(add func(...string)) {
|
||||
for _, row := range outcome.Outcomes {
|
||||
add(
|
||||
row.Implementation,
|
||||
row.Model,
|
||||
row.Cell,
|
||||
strconv.Itoa(row.RunsUsable),
|
||||
joinOrDash(row.FiredProperties),
|
||||
joinOrDash(row.ViolatedClauses),
|
||||
strconv.Itoa(len(row.CannotTellClauses)),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if len(outcome.Excluded) > 0 {
|
||||
fmt.Fprintf(out, "\n%d implementation(s) carry no cell: missing data, never a clean run\n", len(outcome.Excluded))
|
||||
writeTable(out, []string{"implementation", "model", "reason", "detail"}, func(add func(...string)) {
|
||||
for _, entry := range outcome.Excluded {
|
||||
add(entry.Implementation, orDash(entry.Model), entry.Reason, orDash(entry.Detail))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "\nspecification portability over the %d scored implementation(s): %d needed a locating adaptation\n",
|
||||
outcome.Portability.Scored, outcome.Portability.WithUnlocatableSurface)
|
||||
if len(outcome.Portability.BySurface) > 0 {
|
||||
writeTable(out, []string{"surface never located", "implementations"}, func(add func(...string)) {
|
||||
for _, surface := range slices.Sorted(maps.Keys(outcome.Portability.BySurface)) {
|
||||
add(surface, strconv.Itoa(outcome.Portability.BySurface[surface]))
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(outcome.Portability.SurfacesReadAsInconclusive) > 0 {
|
||||
fmt.Fprintf(out, "a miss on %s cannot be told from that surface being legitimately absent, so neither counts against portability\n",
|
||||
strings.Join(outcome.Portability.SurfacesReadAsInconclusive, ", "))
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "\nclause coverage: %d mapped propert(ies) cover %d of %d clauses\n",
|
||||
outcome.Coverage.MappedProperties, len(outcome.Coverage.ClausesCovered), clauseCount)
|
||||
if len(outcome.Coverage.ClausesUncovered) > 0 {
|
||||
fmt.Fprintf(out, "no property covers %s\n", strings.Join(outcome.Coverage.ClausesUncovered, ", "))
|
||||
}
|
||||
fmt.Fprintf(out, "review cost over the scored implementations: %d minutes\n", outcome.ReviewMinutes)
|
||||
|
||||
for _, note := range outcome.Notes {
|
||||
fmt.Fprintf(out, "\nnote: %s\n", note)
|
||||
}
|
||||
}
|
||||
|
||||
func implementationRows(outcome result) []labelledMatrix {
|
||||
rows := []labelledMatrix{{
|
||||
Label: "all",
|
||||
Value: clauseMatrix{matrix: outcome.Implementations},
|
||||
Excluded: len(outcome.Excluded),
|
||||
}}
|
||||
for _, model := range outcome.ByModel {
|
||||
rows = append(rows, labelledMatrix{
|
||||
Label: model.Model,
|
||||
Value: clauseMatrix{matrix: model.Implementations},
|
||||
Excluded: model.Excluded,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func clauseRows(outcome result) []labelledMatrix {
|
||||
rows := []labelledMatrix{{Label: "all", Value: outcome.Clauses}}
|
||||
for _, model := range outcome.ByModel {
|
||||
rows = append(rows, labelledMatrix{Label: model.Model, Value: model.Clauses})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func writeTable(out io.Writer, header []string, rows func(add func(...string))) {
|
||||
writer := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(writer, strings.Join(header, "\t"))
|
||||
rows(func(cells ...string) {
|
||||
fmt.Fprintln(writer, strings.Join(cells, "\t"))
|
||||
})
|
||||
writer.Flush()
|
||||
}
|
||||
|
||||
func formatRatio(value *float64) string {
|
||||
if value == nil {
|
||||
return "n/a"
|
||||
}
|
||||
return strconv.FormatFloat(*value, 'f', 3, 64)
|
||||
}
|
||||
|
||||
func joinOrDash(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strings.Join(values, " ")
|
||||
}
|
||||
|
||||
func orDash(value string) string {
|
||||
if value == "" {
|
||||
return "-"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
clauseMeets = "meets"
|
||||
clauseViolates = "violates"
|
||||
clauseCannotTell = "cannot tell"
|
||||
)
|
||||
|
||||
const (
|
||||
overallDefective = "defective"
|
||||
overallNotDefective = "not defective"
|
||||
)
|
||||
|
||||
// reviewVerdict is one filed verdict form: the twenty clause rows and the
|
||||
// overall verdict review-protocol.md requires, with any adjudicated label
|
||||
// already substituted for the first rater's.
|
||||
type reviewVerdict struct {
|
||||
Implementation string
|
||||
Path string
|
||||
Reviewer string
|
||||
Date string
|
||||
Minutes int
|
||||
Overall string
|
||||
Clauses map[string]string
|
||||
Adjudicated []string
|
||||
}
|
||||
|
||||
func (v reviewVerdict) defective() bool { return v.Overall == overallDefective }
|
||||
|
||||
func (v reviewVerdict) violatedClauses() []string {
|
||||
var violated []string
|
||||
for _, clause := range allClauses() {
|
||||
if v.Clauses[clause] == clauseViolates {
|
||||
violated = append(violated, clause)
|
||||
}
|
||||
}
|
||||
return violated
|
||||
}
|
||||
|
||||
type malformedReview struct {
|
||||
Implementation string
|
||||
Path string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type reviewSide struct {
|
||||
Directory string
|
||||
Verdicts []reviewVerdict
|
||||
Malformed []malformedReview
|
||||
}
|
||||
|
||||
var (
|
||||
reviewFileName = regexp.MustCompile(`^(impl-\d+)\.md$`)
|
||||
adjudicationName = regexp.MustCompile(`^(impl-\d+)-adjudication\.md$`)
|
||||
secondRaterName = regexp.MustCompile(`^impl-\d+-r2\.md$`)
|
||||
keyValueLine = regexp.MustCompile(`(?i)^\s*[-*]?\s*(reviewer|date|minutes|overall verdict|overall)\s*:\s*(.+?)\s*$`)
|
||||
leadingWholeNumbers = regexp.MustCompile(`^\d+`)
|
||||
)
|
||||
|
||||
func loadReviews(directory string) (reviewSide, error) {
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return reviewSide{}, fmt.Errorf("read reviews: %w", err)
|
||||
}
|
||||
side := reviewSide{Directory: directory}
|
||||
adjudications := map[string]map[string]string{}
|
||||
var forms []struct {
|
||||
name string
|
||||
path string
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(directory, entry.Name())
|
||||
switch {
|
||||
case secondRaterName.MatchString(entry.Name()):
|
||||
continue
|
||||
case adjudicationName.MatchString(entry.Name()):
|
||||
name := adjudicationName.FindStringSubmatch(entry.Name())[1]
|
||||
resolved, err := readAdjudication(path)
|
||||
if err != nil {
|
||||
side.Malformed = append(side.Malformed, malformedReview{
|
||||
Implementation: name, Path: path, Reason: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
adjudications[name] = resolved
|
||||
case reviewFileName.MatchString(entry.Name()):
|
||||
forms = append(forms, struct {
|
||||
name string
|
||||
path string
|
||||
}{reviewFileName.FindStringSubmatch(entry.Name())[1], path})
|
||||
}
|
||||
}
|
||||
|
||||
for _, form := range forms {
|
||||
verdict, err := readReview(form.name, form.path)
|
||||
if err != nil {
|
||||
side.Malformed = append(side.Malformed, malformedReview{
|
||||
Implementation: form.name, Path: form.path, Reason: err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
for clause, label := range adjudications[form.name] {
|
||||
if verdict.Clauses[clause] != label {
|
||||
verdict.Adjudicated = append(verdict.Adjudicated, clause)
|
||||
}
|
||||
verdict.Clauses[clause] = label
|
||||
}
|
||||
slices.Sort(verdict.Adjudicated)
|
||||
side.Verdicts = append(side.Verdicts, verdict)
|
||||
}
|
||||
slices.SortFunc(side.Verdicts, func(a, b reviewVerdict) int {
|
||||
return strings.Compare(a.Implementation, b.Implementation)
|
||||
})
|
||||
slices.SortFunc(side.Malformed, func(a, b malformedReview) int {
|
||||
return strings.Compare(a.Path, b.Path)
|
||||
})
|
||||
return side, nil
|
||||
}
|
||||
|
||||
func readReview(name, path string) (reviewVerdict, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return reviewVerdict{}, err
|
||||
}
|
||||
verdict := reviewVerdict{Implementation: name, Path: path, Clauses: map[string]string{}}
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
match := keyValueLine.FindStringSubmatch(normalizeCell(line))
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(match[2])
|
||||
switch strings.ToLower(match[1]) {
|
||||
case "reviewer":
|
||||
verdict.Reviewer = value
|
||||
case "date":
|
||||
verdict.Date = value
|
||||
case "minutes":
|
||||
if digits := leadingWholeNumbers.FindString(value); digits != "" {
|
||||
verdict.Minutes, _ = strconv.Atoi(digits)
|
||||
}
|
||||
case "overall", "overall verdict":
|
||||
overall, ok := canonicalOverall(value)
|
||||
if !ok {
|
||||
return reviewVerdict{}, fmt.Errorf("overall verdict %q is neither %s nor %s", value, overallDefective, overallNotDefective)
|
||||
}
|
||||
verdict.Overall = overall
|
||||
}
|
||||
}
|
||||
clauses, err := readClauseRows(string(body))
|
||||
if err != nil {
|
||||
return reviewVerdict{}, err
|
||||
}
|
||||
verdict.Clauses = clauses
|
||||
for _, clause := range allClauses() {
|
||||
if _, filed := verdict.Clauses[clause]; !filed {
|
||||
return reviewVerdict{}, fmt.Errorf("no row for clause %s: the form must carry all %d", clause, clauseCount)
|
||||
}
|
||||
}
|
||||
if verdict.Overall == "" {
|
||||
return reviewVerdict{}, fmt.Errorf("no overall verdict: the matrix scores the reviewer's own %s or %s", overallDefective, overallNotDefective)
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
func readClauseRows(body string) (map[string]string, error) {
|
||||
clauses := map[string]string{}
|
||||
for _, row := range parseTableRows(body) {
|
||||
clause, ok := canonicalClause(row.cell(0))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label, ok := canonicalClauseVerdict(row.cell(1))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("clause %s has verdict %q, want %s, %s or %s",
|
||||
clause, row.cell(1), clauseMeets, clauseViolates, clauseCannotTell)
|
||||
}
|
||||
if existing, seen := clauses[clause]; seen && existing != label {
|
||||
return nil, fmt.Errorf("clause %s is filed twice, as %q and %q", clause, existing, label)
|
||||
}
|
||||
clauses[clause] = label
|
||||
}
|
||||
return clauses, nil
|
||||
}
|
||||
|
||||
func readAdjudication(path string) (map[string]string, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved, err := readClauseRows(string(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resolved) == 0 {
|
||||
return nil, fmt.Errorf("no resolved clause rows")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func canonicalClauseVerdict(value string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case clauseMeets, "meet", "met":
|
||||
return clauseMeets, true
|
||||
case clauseViolates, "violate", "violated":
|
||||
return clauseViolates, true
|
||||
case clauseCannotTell, "cannot-tell", "cannot_tell", "can't tell", "cant tell":
|
||||
return clauseCannotTell, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalOverall(value string) (string, bool) {
|
||||
cleaned := strings.ToLower(strings.TrimSpace(value))
|
||||
cleaned = strings.TrimSuffix(cleaned, ".")
|
||||
switch cleaned {
|
||||
case overallDefective:
|
||||
return overallDefective, true
|
||||
case overallNotDefective, "not-defective", "no defect", "clean":
|
||||
return overallNotDefective, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// tableRow is one pipe-delimited markdown row with its 1-based line number, so
|
||||
// a malformed cell can name the line the author has to go and fix.
|
||||
type tableRow struct {
|
||||
Line int
|
||||
Cells []string
|
||||
}
|
||||
|
||||
var separatorCell = regexp.MustCompile(`^:?-{2,}:?$`)
|
||||
|
||||
func parseTableRows(body string) []tableRow {
|
||||
var rows []tableRow
|
||||
for index, raw := range strings.Split(body, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(line, "|") {
|
||||
continue
|
||||
}
|
||||
cells := splitCells(line)
|
||||
if len(cells) == 0 || isSeparatorRow(cells) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, tableRow{Line: index + 1, Cells: cells})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func splitCells(line string) []string {
|
||||
trimmed := strings.Trim(line, "|")
|
||||
parts := strings.Split(trimmed, "|")
|
||||
cells := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
cells = append(cells, normalizeCell(part))
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func isSeparatorRow(cells []string) bool {
|
||||
for _, cell := range cells {
|
||||
if !separatorCell.MatchString(cell) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeCell(value string) string {
|
||||
cleaned := strings.ReplaceAll(value, "**", "")
|
||||
cleaned = strings.ReplaceAll(cleaned, "`", "")
|
||||
return strings.Join(strings.Fields(cleaned), " ")
|
||||
}
|
||||
|
||||
func (row tableRow) cell(index int) string {
|
||||
if index >= len(row.Cells) {
|
||||
return ""
|
||||
}
|
||||
return row.Cells[index]
|
||||
}
|
||||
|
||||
var listSeparators = regexp.MustCompile(`[,\s]+`)
|
||||
|
||||
func splitList(value string) []string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
var items []string
|
||||
for _, item := range listSeparators.Split(trimmed, -1) {
|
||||
if item != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
Reference in new issue
Block a user