aboutsummaryrefslogtreecommitdiff
path: root/cmds/score.go
blob: da6167a08bce7c2d35b1354c1e8f54c7bfec1777 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// Score command: debugging utility, one trained model vs. stdin titles.
//
// Reads one title per line on stdin, writes {"title", "score"} JSONL on
// stdout. Exists to make the vendored ML core exercisable end-to-end before
// the real commands (export/train/serve) land, and stays useful later for
// poking at model behavior by hand.
package cmds

import (
	"bufio"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"fluxrec/core"
)

type ScoreCommand struct {
	ModelPath string
}

func (c *ScoreCommand) Name() string { return "score" }

func (c *ScoreCommand) Init(args []string) error {
	fs := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
	fs.Usage = func() {
		fmt.Fprint(fs.Output(), `Usage: fluxrec score --model MODEL

Score stdin titles (one per line) with a trained model, JSONL to stdout.
`)
		fs.PrintDefaults()
	}
	fs.StringVar(&c.ModelPath, "model", "", "Path to model.json (required)")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if c.ModelPath == "" {
		return errors.New("--model flag is required")
	}
	clean := filepath.Clean(c.ModelPath)
	if strings.Contains(clean, "..") {
		return fmt.Errorf("invalid model path %q: must not contain '..'", c.ModelPath)
	}
	c.ModelPath = clean
	return nil
}

type scoreResult struct {
	Title string  `json:"title"`
	Score float64 `json:"score"`
}

func (c *ScoreCommand) Run(stdin io.Reader, stdout io.Writer) error {
	model, err := loadModel(c.ModelPath)
	if err != nil {
		return err
	}
	vectorizer := core.CreateVectorizerFromModel(model)

	enc := json.NewEncoder(stdout)
	scanner := bufio.NewScanner(stdin)
	scanner.Buffer(make([]byte, 64*1024), 1024*1024)
	for scanner.Scan() {
		title := strings.TrimSpace(scanner.Text())
		if title == "" {
			continue
		}
		if len(title) > 1000 {
			return fmt.Errorf("title exceeds 1000 chars")
		}
		vec := vectorizer.Transform([]string{title})
		score, err := core.PredictScore(vec[0], model.Weights)
		if err != nil {
			return fmt.Errorf("scoring %q: %w", title, err)
		}
		if err := enc.Encode(scoreResult{Title: title, Score: score}); err != nil {
			return err
		}
	}
	return scanner.Err()
}

func loadModel(path string) (*core.ModelEnvelope, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("opening model: %w", err)
	}
	defer func() { _ = f.Close() }()

	var model core.ModelEnvelope
	if err := json.NewDecoder(f).Decode(&model); err != nil {
		return nil, fmt.Errorf("parsing model %s: %w", path, err)
	}
	if len(model.Weights) == 0 || len(model.OrderedVocab) == 0 {
		return nil, fmt.Errorf("model %s is missing weights or vocabulary", path)
	}
	return &model, nil
}