aboutsummaryrefslogtreecommitdiff
path: root/cmds/relabel.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmds/relabel.go')
-rw-r--r--cmds/relabel.go77
1 files changed, 77 insertions, 0 deletions
diff --git a/cmds/relabel.go b/cmds/relabel.go
new file mode 100644
index 0000000..9233daf
--- /dev/null
+++ b/cmds/relabel.go
@@ -0,0 +1,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
+}