aboutsummaryrefslogtreecommitdiff
path: root/cmds/relabel.go
blob: 9233dafffad1b5b725e7f09ee373ad1a4a23db95 (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
// Star-driven relabeling of the label store.
//
// Negatives are sampled from read+unstarred entries, so "sampled as a
// negative, then starred" is a real trajectory: at capture time the entry was
// unstarred, and later samsc read it and liked it. Without relabeling that
// star changes nothing — the entry is already in the store, so it is deduped
// away by ID or URL — and the row keeps teaching the model that samsc did not
// want the very article he starred.
//
// That is the one feedback channel the recommendations feed creates (a star on
// a fluxrec pick whose original was already labeled), so it has to land as a
// positive rather than be swallowed as a duplicate.
//
// Flipping rewrites labels.jsonl in place: the same deliberate exception
// compact-labels takes (see core/labels.go). The row keeps its identity
// (entry, feed, title) and only its label and captured_at change, so the star
// reads as recent evidence — which is what the time-split validation window
// should see. The original file is copied to a backup before the rewrite.
package cmds

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

	"fluxrec/core"
)

// relabelStars flips negative rows to positives for entries starred since they
// were labeled. ids and urls are this run's stars: entry IDs as seen on the
// starred entries, and normalized URLs (which is how a star on a rec-feed item
// resolves to the original row). Rows are mutated in place; the count of
// flipped rows is returned.
func relabelStars(rows []core.LabelRow, ids map[int64]bool, urls map[string]bool, now time.Time) int {
	flipped := 0
	for i := range rows {
		if rows[i].Label != core.LabelNeg {
			continue
		}
		if !ids[rows[i].EntryID] && !urls[core.NormalizeURL(rows[i].URL)] {
			continue
		}
		rows[i].Label = core.LabelPos
		rows[i].CapturedAt = now
		flipped++
	}
	return flipped
}

// rewriteLabels replaces path with rows, copying the original to backup first
// (backup == "" skips the copy). Same rules as compact-labels' rewrite: the
// backup is written before anything is touched, so an interrupted rewrite
// leaves a recoverable file.
func rewriteLabels(path, backup string, rows []core.LabelRow) error {
	orig, err := os.ReadFile(path)
	if err != nil {
		return fmt.Errorf("reading %s for backup: %w", path, err)
	}
	if backup != "" {
		if err := os.WriteFile(backup, orig, 0o644); err != nil {
			return fmt.Errorf("writing backup %s: %w", backup, err)
		}
	}
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	for _, row := range rows {
		if err := enc.Encode(row); err != nil {
			return fmt.Errorf("encoding label row: %w", err)
		}
	}
	if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
		return fmt.Errorf("rewriting %s: %w", path, err)
	}
	return nil
}