aboutsummaryrefslogtreecommitdiff
path: root/cmds/relabel.go
blob: 3411b0d11b0d9f4b1180e408d5e2f27185f0b236 (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
104
105
// 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"
)

// starsByKey is this run's stars, each with the moment the star happened.
// Keyed by entry ID and by normalized URL, because a star on a rec-feed item
// is only tied to the original row by its URL.
type starsByKey struct {
	byID  map[int64]time.Time
	byURL map[string]time.Time
}

func (s *starsByKey) add(id int64, url string, at time.Time) {
	if s.byID == nil {
		s.byID = make(map[int64]time.Time)
		s.byURL = make(map[string]time.Time)
	}
	s.byID[id] = at
	s.byURL[core.NormalizeURL(url)] = at
}

// stamp returns when the row's entry was starred, if it was.
func (s starsByKey) stamp(row core.LabelRow) (time.Time, bool) {
	if t, ok := s.byID[row.EntryID]; ok {
		return t, true
	}
	if t, ok := s.byURL[core.NormalizeURL(row.URL)]; ok {
		return t, true
	}
	return time.Time{}, false
}

// relabelStars flips negative rows to positives for entries starred since they
// were labeled, stamping each with the star's own time (falling back to the
// run clock when a row's star time is unknown). Rows are mutated in place; the
// count of flipped rows is returned.
func relabelStars(rows []core.LabelRow, stars starsByKey, now time.Time) int {
	flipped := 0
	for i := range rows {
		if rows[i].Label != core.LabelNeg {
			continue
		}
		at, ok := stars.stamp(rows[i])
		if !ok {
			continue
		}
		rows[i].Label = core.LabelPos
		rows[i].CapturedAt = at
		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
}