Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

calls_detect_anomalies.go
package cmd

import (
	"encoding/json"
	"fmt"
	"os"

	"skraak/tools/calls"
)

func printDetectAnomaliesUsage() {
	fmt.Fprintf(os.Stderr, "Usage: skraak calls detect-anomalies [options]\n\n")
	fmt.Fprintf(os.Stderr, "Compare corresponding segments across ML model filters and flag disagreements.\n")
	fmt.Fprintf(os.Stderr, "Segments are matched by time overlap. Lonely segments (no overlap in all models) are skipped.\n\n")
	fmt.Fprintf(os.Stderr, "Options:\n")
	fmt.Fprintf(os.Stderr, "  --folder <path>     Folder containing .data files (required)\n")
	fmt.Fprintf(os.Stderr, "  --model <name>      Filter name to compare (required, repeat for each model, min 2)\n")
	fmt.Fprintf(os.Stderr, "  --species <name>    Scope to species or species+calltype (optional, repeat to add more)\n")
	fmt.Fprintf(os.Stderr, "\nAnomaly types:\n")
	fmt.Fprintf(os.Stderr, "  label_mismatch      Species or calltype disagrees across models\n")
	fmt.Fprintf(os.Stderr, "  certainty_mismatch  Labels agree but certainty values differ\n")
	fmt.Fprintf(os.Stderr, "\nExamples:\n")
	fmt.Fprintf(os.Stderr, "  skraak calls detect-anomalies --folder ./data \\\n")
	fmt.Fprintf(os.Stderr, "    --model opensoundscape-kiwi-1.0 --model opensoundscape-kiwi-1.2\n")
	fmt.Fprintf(os.Stderr, "  skraak calls detect-anomalies --folder ./data \\\n")
	fmt.Fprintf(os.Stderr, "    --model opensoundscape-kiwi-1.0 --model opensoundscape-kiwi-1.2 --model opensoundscape-kiwi-1.5 \\\n")
	fmt.Fprintf(os.Stderr, "    --species Kiwi+Duet --species Kiwi+Male\n")
}

// runCallsDetectAnomalies compares segments across ML model filters and flags disagreements.
//
// JSON output schema:
//
//	{
//	  "folder": string,                  // Folder path
//	  "models": [string],               // Model filter names compared
//	  "files_examined": int,            // Total .data files examined
//	  "files_with_all_models": int,     // Files containing all specified models
//	  "anomalies_total": int,           // Total anomalies found
//	  "label_mismatches": int,          // Species/calltype disagreements
//	  "certainty_mismatches": int,      // Certainty disagreements
//	  "anomalies": [                    // Anomaly details (omitted if empty)
//	    {
//	      "file": string,             // .data filename
//	      "type": string,             // "label_mismatch" | "certainty_mismatch"
//	      "segments": [
//	        {
//	          "model": string,        // Filter name
//	          "start": float,         // Segment start (seconds)
//	          "end": float,           // Segment end (seconds)
//	          "species": string,      // Species name
//	          "calltype": string,     // Call type (omitted if empty)
//	          "certainty": int        // Certainty level (0-100)
//	        }
//	      ]
//	    }
//	  ],
//	  "error": string                    // Error message (omitted if empty)
//	}
//
// detectAnomaliesArgs holds the parsed arguments.
type detectAnomaliesArgs struct {
	folder  string
	models  []string
	species []string
}

// parseDetectAnomaliesArgs parses CLI arguments for the detect-anomalies command.
func parseDetectAnomaliesArgs(args []string) (detectAnomaliesArgs, error) {
	var da detectAnomaliesArgs
	i := 0
	for i < len(args) {
		arg := args[i]
		switch arg {
		case "--folder":
			if i+1 >= len(args) {
				return da, fmt.Errorf("--folder requires a value")
			}
			da.folder = args[i+1]
			i += 2
		case "--model":
			if i+1 >= len(args) {
				return da, fmt.Errorf("--model requires a value")
			}
			da.models = append(da.models, args[i+1])
			i += 2
		case "--species":
			if i+1 >= len(args) {
				return da, fmt.Errorf("--species requires a value")
			}
			da.species = append(da.species, args[i+1])
			i += 2
		case "--help", "-h":
			printDetectAnomaliesUsage()
			return da, nil
		default:
			fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n\n", arg)
			printDetectAnomaliesUsage()
			return da, fmt.Errorf("unknown flag: %s", arg)
		}
	}
	return da, nil
}

// validateDetectAnomaliesArgs checks required flags.
func validateDetectAnomaliesArgs(da detectAnomaliesArgs) error {
	if da.folder == "" {
		printDetectAnomaliesUsage()
		return fmt.Errorf("--folder is required")
	}
	if len(da.models) < 2 {
		printDetectAnomaliesUsage()
		return fmt.Errorf("at least 2 --model values required")
	}
	return nil
}

func runCallsDetectAnomalies(args []string) error {
	da, err := parseDetectAnomaliesArgs(args)
	if err != nil {
		return err
	}
	if err := validateDetectAnomaliesArgs(da); err != nil {
		return err
	}

	output, err := calls.DetectAnomalies(calls.DetectAnomaliesInput{
		Folder:  da.folder,
		Models:  da.models,
		Species: da.species,
	})
	if err != nil {
		return fmt.Errorf("detect-anomalies: %w", err)
	}

	fmt.Fprintf(os.Stderr, "Examined %d files, %d had all models\n",
		output.FilesExamined, output.FilesWithAllModels)
	fmt.Fprintf(os.Stderr, "Anomalies: %d total (%d label, %d certainty)\n",
		output.AnomaliesTotal, output.LabelMismatches, output.CertaintyMismatches)

	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(output); err != nil {
		return fmt.Errorf("encoding output: %w", err)
	}
	return nil
}