calls_propagate.go
package cmd
import (
"encoding/json"
"flag"
"fmt"
"os"
"skraak/tools/calls"
)
// runCallsPropagate propagates verified classifications between filters in .data files.
//
// JSON output schema (--file mode):
//
// {
// "file": string, // .data file path
// "from_filter": string, // Source filter name
// "to_filter": string, // Target filter name
// "species": string, // Species propagated
// "filters_missing": bool, // True if file lacks one or both filters (omitted if false)
// "targets_examined": int, // Target labels examined
// "propagated": int, // Target labels updated
// "skipped_no_overlap": int, // Targets with no overlapping source
// "skipped_conflict": int, // Targets with conflicting sources
// "conflicts": [ // Conflict details (omitted if empty)
// {
// "file": string, // .data filename (omitted in single-file mode)
// "target_start": float, // Target segment start (seconds)
// "target_end": float, // Target segment end (seconds)
// "target_calltype": string, // Target call type (omitted if empty)
// "source_choices": [
// {
// "start": float, // Source segment start
// "end": float, // Source segment end
// "species": string, // Source species
// "calltype": string // Source call type (omitted if empty)
// }
// ]
// }
// ],
// "changes": [ // Change details (omitted if empty)
// {
// "target_start": float, // Target segment start
// "target_end": float, // Target segment end
// "prev_species": string, // Previous species
// "prev_calltype": string, // Previous call type (omitted if empty)
// "prev_certainty": int, // Previous certainty
// "new_species": string, // New species
// "new_calltype": string, // New call type (omitted if empty)
// "new_certainty": int // New certainty
// }
// ],
// "error": string // Error message (omitted if empty)
// }
//
// JSON output schema (--folder mode):
//
// {
// "folder": string, // Folder path
// "from_filter": string, // Source filter name
// "to_filter": string, // Target filter name
// "species": string, // Species propagated
// "files_total": int, // Total .data files scanned
// "files_with_both_filters": int, // Files containing both filters
// "files_skipped_no_filter": int, // Files missing a filter
// "files_changed": int, // Files with at least one propagation
// "files_errored": int, // Files with errors
// "targets_examined": int, // Total target labels examined
// "propagated": int, // Total target labels updated
// "skipped_no_overlap": int, // Targets with no overlapping source
// "skipped_conflict": int, // Targets with conflicting sources
// "conflicts": [PropagateConflict], // See --file mode conflict schema
// "errors": [CallsPropagateOutput], // Per-file error outputs (omitted if empty)
// "error": string // Top-level error (omitted if empty)
// }
func runCallsPropagate(args []string) error {
fs := flag.NewFlagSet("calls propagate", flag.ExitOnError)
file := fs.String("file", "", "Path to a single .data file (mutually exclusive with --folder)")
folder := fs.String("folder", "", "Path to folder containing .data files (mutually exclusive with --file)")
from := fs.String("from", "", "Source filter name (required)")
to := fs.String("to", "", "Target filter name (required)")
species := fs.String("species", "", "Species to propagate (required, e.g. Kiwi)")
fs.Usage = usagePrinter(fs,
"skraak calls propagate [options]",
"Propagate verified classifications from one filter to another within a .data file\n"+
"or across every .data file in a folder.\n\n"+
"Only source labels with certainty=100 and matching --species are considered.\n"+
"Target labels (filter=--to) are updated when their certainty is 70 or 0.\n"+
"Updated target labels are set to certainty=90; file reviewer is set to \"Skraak\".\n"+
"Targets already at certainty=100 or 90 are left alone.\n"+
"Files that do not contain both --from and --to filter labels are skipped.\n\n"+
"Exactly one of --file or --folder is required.",
"skraak calls propagate --file rec.wav.data \\",
" --from opensoundscape-kiwi-1.2 --to opensoundscape-kiwi-1.5 --species Kiwi",
"",
"skraak calls propagate --folder ./recordings \\",
" --from opensoundscape-kiwi-1.2 --to opensoundscape-kiwi-1.5 --species Kiwi",
)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("parsing arguments: %w", err)
}
if (*file == "") == (*folder == "") {
fs.Usage()
return fmt.Errorf("exactly one of --file or --folder is required")
}
if err := requireFlags(fs, map[string]any{"--from": *from, "--to": *to, "--species": *species}); err != nil {
return err
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if *file != "" {
result, err := calls.CallsPropagate(calls.CallsPropagateInput{
File: *file,
FromFilter: *from,
ToFilter: *to,
Species: *species,
})
if err != nil {
return fmt.Errorf("propagate: %s", result.Error)
}
if err := enc.Encode(result); err != nil {
return fmt.Errorf("encoding output: %w", err)
}
return nil
}
result, err := calls.CallsPropagateFolder(calls.CallsPropagateFolderInput{
Folder: *folder,
FromFilter: *from,
ToFilter: *to,
Species: *species,
})
if err != nil {
return fmt.Errorf("propagate: %s", result.Error)
}
fmt.Fprintf(os.Stderr,
"Files: %d total, %d with both filters, %d skipped (missing filter), %d changed, %d errored\n",
result.FilesTotal, result.FilesWithBothFilters, result.FilesSkippedNoFilter,
result.FilesChanged, result.FilesErrored)
fmt.Fprintf(os.Stderr,
"Targets: %d examined, %d propagated, %d no-overlap, %d conflicts\n",
result.TargetsExamined, result.Propagated, result.SkippedNoOverlap, result.SkippedConflict)
if err := enc.Encode(result); err != nil {
return fmt.Errorf("encoding output: %w", err)
}
return nil
}