// 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 }