miller/pkg/transformers/fraction.go
John Kerl f637633420
Tier-2 structured verb options: OptionSpec, initial migration (#2098) (#2111)
* Tier-2 structured verb options: OptionSpec, initial migration (#2098)

PR 3 of the AI-friendly roadmap (plans/plan-2098-llm.md).

Infrastructure:
- Add OptionSpec{Flag,Arg,Type,Desc,Repeatable,Values} to
  pkg/transformers/aaa_record_transformer.go alongside TransformerSetup.
  Type is one of: bool, string, int, float, csv-list, regex, filename,
  format, enum. For type=="enum", Values lists the valid choices.
- Add Options []OptionSpec to TransformerSetup (nil = not yet migrated).
- Emit Options in VerbInfoForJSON (omitempty so unmigrated verbs stay
  backward-compatible; agents check key presence for Tier-2 availability).
  UsageText is always present as the Tier-1 prose fallback.
- Add VerbOptionsNilCheck() in aaa_verb_options_check.go: progress report
  of migrated vs. unmigrated verbs, analogous to FLAG_TABLE.NilCheck().
- Wire verb-options-nil-check into mlr help (internal/docgen section).

Initial migration (5/70 verbs):
- nothing: empty Options (no verb-specific options, explicitly migrated)
- cat: -n (bool), -N (string), -g (csv-list), --filename, --filenum (bool)
- head: -g (csv-list), -n (int)
- tail: -g (csv-list), -n (int)
- tee: -a, -p (bool)

Tests:
- 5 new unit tests in aaa_transformer_json_test.go covering migrated/
  unmigrated paths, field population, JSON round-trip, and key-presence.
- Regression test case 0003: mlr help verb-options-nil-check golden output.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Migrate all 70 verbs to structured OptionSpec; bump catalog schema to v2

Completes the Tier-2 migration started in the previous commit. Every verb
in TRANSFORMER_LOOKUP_TABLE now has a non-nil Options field.

- Workflow-migrated all 65 remaining verbs. Each Setup var now carries
  Options: []OptionSpec{...} with Flag/Arg/Type/Desc fields. Verbs with
  no verb-specific options (altkv, check, group-like, nothing, etc.) use
  an empty slice to signal "migrated but no options."
- Drop `omitempty` from VerbInfoForJSON.Options: empty slices were silently
  dropped, making migrated-no-option verbs indistinguishable from unmigrated
  ones in JSON. Without omitempty: null=unmigrated, []=migrated-no-options,
  [...]= migrated-with-options. Bump catalogSchemaVersion 1→2 for this shape
  change.
- Replace the two "unmigrated-verb" unit tests (which used stats1 as an
  example) with TestAllVerbsFullyMigrated (asserts every verb has non-nil
  Options) and TestAllVerbsHaveOptionsKeyInJSON (asserts every migrated
  verb emits the "options" key in JSON).
- Regenerate test/cases/cli-help/0003/expout: now reads
  "Verb options migration: 70/70 migrated."

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* remove a transitional helper

* git rms

* Render verb usage Options blocks from structured OptionSpec

Each verb's usage message and its Tier-2 OptionSpec list previously
duplicated the option text. New WriteVerbOptions (aaa_verb_usage.go)
renders the "Options:" block from the specs: aligned flag column,
descriptions word-wrapped at 80, uniform trailing -h|--help line.

- OptionSpec gains Aliases (JSON "aliases") so long-form spellings
  like join's --lk|--left-keep-field-names survive in both outputs
- All 70 verbs migrated; options literals hoisted to package-level
  vars (usage funcs can't reference their Setup var without a Go
  init cycle)
- Hand-written per-option details the specs had condensed away are
  merged into Desc, enriching the JSON catalog
- Non-option prose (examples, cross-references, dynamic accumulator
  listings) kept verbatim
- Regenerated the six usage-embedding regression expectations and
  the two affected doc pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix pre-existing usage-text bugs surfaced by the OptionSpec migration

- gap: usage said "One of -f or -g is required" but the parser takes
  -n or -g
- seqgen: drop description line copy-pasted from cat ("Passes input
  records directly to output...") which contradicted "Discards the
  input record stream"
- utf8-to-latin1: description read inverted ("from Latin-1 to UTF-8")
- sec2gmtdate: usage said "../c/mlr" instead of "mlr"
- top: document the accepted-but-undocumented --max flag
- stats2: add linreg-pca to the -a enum values, matching the runtime
  accumulator table

Regression expectations and docs regenerated accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix check usage sentence order; stats1 usage blank line to usage stream

- check: the description's second and third lines were swapped,
  reading "Consumes records without printing any output, / Useful for
  doing a well-formatted check on input data. / with the exception
  that warnings are printed to stderr."
- stats1: a bare fmt.Println() in the usage func wrote its blank line
  to process stdout instead of the usage output stream

Regression expectation and docs regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-07-03 14:27:23 -04:00

292 lines
8.9 KiB
Go

package transformers
import (
"errors"
"fmt"
"os"
"strings"
"github.com/johnkerl/miller/v6/pkg/bifs"
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/lib"
"github.com/johnkerl/miller/v6/pkg/mlrval"
"github.com/johnkerl/miller/v6/pkg/types"
)
const verbNameFraction = "fraction"
var fractionOptions = []OptionSpec{
{Flag: "-f", Arg: "{a,b,c}", Type: "csv-list", Desc: "Field name(s) for fraction calculation"},
{Flag: "-g", Arg: "{d,e,f}", Type: "csv-list", Desc: "Optional group-by-field name(s) for fraction counts"},
{Flag: "-p", Type: "bool", Desc: "Produce percents [0..100], not fractions [0..1]. Output field names end with \"_percent\" rather than \"_fraction\""},
{Flag: "-c", Type: "bool", Desc: "Produce cumulative distributions, i.e. running sums: each output value folds in the sum of the previous for the specified group. E.g. with input records x=1 x=2 x=3 and x=4, emits output records x=1,x_cumulative_fraction=0.1 x=2,x_cumulative_fraction=0.3 x=3,x_cumulative_fraction=0.6 and x=4,x_cumulative_fraction=1.0"},
}
var FractionSetup = TransformerSetup{
Verb: verbNameFraction,
UsageFunc: transformerFractionUsage,
ParseCLIFunc: transformerFractionParseCLI,
IgnoresInput: false,
Options: fractionOptions,
}
func transformerFractionUsage(
o *os.File,
) {
argv0 := "mlr"
verb := verbNameFraction
fmt.Fprintf(o, "Usage: %s %s [options]\n", argv0, verb)
fmt.Fprintf(o, "For each record's value in specified fields, computes the ratio of that\n")
fmt.Fprintf(o, "value to the sum of values in that field over all input records.\n")
fmt.Fprintf(o, "E.g. with input records x=1 x=2 x=3 and x=4, emits output records\n")
fmt.Fprintf(o, "x=1,x_fraction=0.1 x=2,x_fraction=0.2 x=3,x_fraction=0.3 and x=4,x_fraction=0.4\n")
fmt.Fprintf(o, "\n")
fmt.Fprintf(o, "Note: this is internally a two-pass algorithm: on the first pass it retains\n")
fmt.Fprintf(o, "input records and accumulates sums; on the second pass it computes quotients\n")
fmt.Fprintf(o, "and emits output records. This means it produces no output until all input is read.\n")
fmt.Fprintf(o, "\n")
WriteVerbOptions(o, fractionOptions)
}
func transformerFractionParseCLI(
pargi *int,
argc int,
args []string,
_ *cli.TOptions,
doConstruct bool, // false for first pass of CLI-parse, true for second pass
) (RecordTransformer, error) {
// Skip the verb name from the current spot in the mlr command line
argi := *pargi
verb := args[argi]
argi++
// Parse local flags
var fractionFieldNames []string = nil
var groupByFieldNames []string = nil
doPercents := false
doCumu := false
var err error
for argi < argc /* variable increment: 1 or 2 depending on flag */ {
opt := args[argi]
if !strings.HasPrefix(opt, "-") {
break // No more flag options to process
}
if args[argi] == "--" {
break // All transformers must do this so main-flags can follow verb-flags
}
argi++
switch opt {
case "-h", "--help":
transformerFractionUsage(os.Stdout)
return nil, cli.ErrHelpRequested
case "-f":
fractionFieldNames, err = cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
if err != nil {
return nil, err
}
case "-g":
groupByFieldNames, err = cli.VerbGetStringArrayArg(verb, opt, args, &argi, argc)
if err != nil {
return nil, err
}
case "-p":
doPercents = true
case "-c":
doCumu = true
default:
return nil, cli.VerbErrorf(verb, "option \"%s\" not recognized", opt)
}
}
if fractionFieldNames == nil {
return nil, cli.VerbErrorf(verb, "-f field names required")
}
*pargi = argi
if !doConstruct { // All transformers must do this for main command-line parsing
return nil, nil
}
transformer, err := NewTransformerFraction(
fractionFieldNames,
groupByFieldNames,
doPercents,
doCumu,
)
if err != nil {
return nil, err
}
return transformer, nil
}
type TransformerFraction struct {
fractionFieldNames []string
groupByFieldNames []string
doCumu bool
recordsAndContexts []*types.RecordAndContext
// Two-level map: Group-by field names are the first keyset;
// fraction field names are keys into the second.
sums map[string]map[string]*mlrval.Mlrval
cumus map[string]map[string]*mlrval.Mlrval
outputFieldNameSuffix string // "_fraction" or "_percent"
multiplier *mlrval.Mlrval // 1.0 for fraction or 100.0 for percent
zero *mlrval.Mlrval
}
func NewTransformerFraction(
fractionFieldNames []string,
groupByFieldNames []string,
doPercents bool,
doCumu bool,
) (*TransformerFraction, error) {
recordsAndContexts := []*types.RecordAndContext{}
sums := make(map[string]map[string]*mlrval.Mlrval)
cumus := make(map[string]map[string]*mlrval.Mlrval)
var multiplier *mlrval.Mlrval
var outputFieldNameSuffix string
if doPercents {
multiplier = mlrval.FromInt(100)
if doCumu {
outputFieldNameSuffix = "_cumulative_percent"
} else {
outputFieldNameSuffix = "_percent"
}
} else {
multiplier = mlrval.FromInt(1)
if doCumu {
outputFieldNameSuffix = "_cumulative_fraction"
} else {
outputFieldNameSuffix = "_fraction"
}
}
zero := mlrval.FromInt(0)
return &TransformerFraction{
fractionFieldNames: fractionFieldNames,
groupByFieldNames: groupByFieldNames,
doCumu: doCumu,
recordsAndContexts: recordsAndContexts,
sums: sums,
cumus: cumus,
outputFieldNameSuffix: outputFieldNameSuffix,
multiplier: multiplier,
zero: zero,
}, nil
}
func (tr *TransformerFraction) Transform(
inrecAndContext *types.RecordAndContext,
outputRecordsAndContexts *[]*types.RecordAndContext, // list of *types.RecordAndContext
inputDownstreamDoneChannel <-chan bool,
outputDownstreamDoneChannel chan<- bool,
) {
HandleDefaultDownstreamDone(inputDownstreamDoneChannel, outputDownstreamDoneChannel)
if !inrecAndContext.EndOfStream { // Not end of stream; pass 1
inrec := inrecAndContext.Record
// Append records into a single output list (so that this verb is order-preserving).
tr.recordsAndContexts = append(tr.recordsAndContexts, inrecAndContext)
// Accumulate sums of fraction-field values grouped by group-by field names
groupingKey, hasAll := inrec.GetSelectedValuesJoined(tr.groupByFieldNames)
if hasAll {
sumsForGroup := tr.sums[groupingKey]
var cumusForGroup map[string]*mlrval.Mlrval = nil
if sumsForGroup == nil {
sumsForGroup = make(map[string]*mlrval.Mlrval)
tr.sums[groupingKey] = sumsForGroup
cumusForGroup = make(map[string]*mlrval.Mlrval)
tr.cumus[groupingKey] = cumusForGroup
}
for _, fractionFieldName := range tr.fractionFieldNames {
value := inrec.Get(fractionFieldName)
if value != nil {
value.AssertNumeric() // may fatal the process
sum := sumsForGroup[fractionFieldName]
if sum == nil { // First value for group
sumsForGroup[fractionFieldName] = value.Copy()
cumusForGroup[fractionFieldName] = tr.zero
} else {
sumsForGroup[fractionFieldName] = bifs.BIF_plus_binary(sum, value)
}
}
}
}
} else { // End of stream; pass 2
// Iterate over the retained records, decorating them with fraction fields.
endOfStreamContext := inrecAndContext.Context
for _, recordAndContext := range tr.recordsAndContexts {
outrec := recordAndContext.Record
groupingKey, hasAll := outrec.GetSelectedValuesJoined(tr.groupByFieldNames)
if hasAll {
sumsForGroup := tr.sums[groupingKey]
cumusForGroup := tr.cumus[groupingKey]
lib.InternalCodingErrorIf(sumsForGroup == nil) // should have been populated on pass 1
for _, fractionFieldName := range tr.fractionFieldNames {
value := outrec.Get(fractionFieldName)
if value != nil {
value.AssertNumeric() // may fatal the process
var numerator *mlrval.Mlrval
var cumu *mlrval.Mlrval
var outputValue *mlrval.Mlrval
if tr.doCumu {
cumu = cumusForGroup[fractionFieldName]
numerator = bifs.BIF_plus_binary(value, cumu)
} else {
numerator = value
}
denominator := sumsForGroup[fractionFieldName]
// Return 0 for 0/n
if mlrval.Equals(numerator, tr.zero) {
outputValue = tr.zero
} else if !mlrval.Equals(denominator, tr.zero) {
outputValue = bifs.BIF_divide(numerator, denominator)
outputValue = bifs.BIF_times(outputValue, tr.multiplier)
} else {
outputValue = mlrval.FromError(
errors.New(
"mlr fraction: division by zero",
),
)
}
outrec.PutCopy(
fractionFieldName+tr.outputFieldNameSuffix,
outputValue,
)
if tr.doCumu {
cumusForGroup[fractionFieldName] = bifs.BIF_plus_binary(cumu, value)
}
}
}
}
*outputRecordsAndContexts = append(*outputRecordsAndContexts, types.NewRecordAndContext(outrec, &endOfStreamContext))
}
tr.recordsAndContexts = tr.recordsAndContexts[:0]
*outputRecordsAndContexts = append(*outputRecordsAndContexts, inrecAndContext) // end-of-stream marker
}
}