orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "fmt"
5 "regexp"
6 "strconv"
7 "strings"
8 "time"
9)
10
11// A question about what is next is answered by a date, so whether an answer
12// answered it is arithmetic and belongs here rather than in a prompt. The
13// contract tells the model which dates can answer and a 4B still writes the
14// ones its passages gave it, which for a team that played on Friday is Friday.
15//
16// The test is deliberately weak. One date anywhere in the answer that is today
17// or later leaves it alone, because a fixture list names the last result as
18// background and that is fine. Nothing at all dated today or later means the
19// answer cannot contain the thing that was asked for, whatever else it holds.
20
21// Abbreviations included, since a fixture list writes "9 Sept 2026" far more
22// often than it writes the month out.
23const monthNames = `(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sept|sep|oct|nov|dec)\.?`
24
25var (
26 isoDate = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`)
27 // "12 September 2026" and "12 September".
28 dayFirst = regexp.MustCompile(`(?i)\b(\d{1,2})(?:st|nd|rd|th)?\s+` + monthNames + `\b(?:,?\s+(\d{4})\b)?`)
29 // "September 12, 2026" and "September 12".
30 monthFirst = regexp.MustCompile(`(?i)\b` + monthNames + `\s+(\d{1,2})(?:st|nd|rd|th)?\b(?:,?\s+(\d{4})\b)?`)
31
32 months = map[string]time.Month{
33 "jan": time.January, "feb": time.February, "mar": time.March,
34 "apr": time.April, "may": time.May, "jun": time.June,
35 "jul": time.July, "aug": time.August, "sep": time.September,
36 "oct": time.October, "nov": time.November, "dec": time.December,
37 }
38)
39
40// datesIn is every date named in the text, each at midnight where the reader
41// is, so a page's UTC timestamp and a fixture written as a bare day compare as
42// the same kind of thing.
43func datesIn(text string, now time.Time) []time.Time {
44 var out []time.Time
45 add := func(t time.Time, ok bool) {
46 if ok {
47 out = append(out, time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, now.Location()))
48 }
49 }
50 for _, m := range isoDate.FindAllString(text, -1) {
51 add(parseDate(m))
52 }
53 for _, m := range dayFirst.FindAllStringSubmatch(text, -1) {
54 add(dateAt(m[1], m[2], m[3], now))
55 }
56 for _, m := range monthFirst.FindAllStringSubmatch(text, -1) {
57 add(dateAt(m[2], m[1], m[3], now))
58 }
59 // A bare month and year is coarse but datable, and it only counts when
60 // nothing more precise was found, since "September 2026" sitting beside
61 // "12 September 2026" is the same date written twice.
62 if len(out) == 0 {
63 for _, m := range monthYear.FindAllStringSubmatch(text, -1) {
64 add(monthStart(m[1], m[2]))
65 }
66 }
67 return out
68}
69
70// dateAt builds a date from the pieces as they were written.
71func dateAt(day, month, year string, now time.Time) (time.Time, bool) {
72 m, ok := months[strings.ToLower(month)[:3]]
73 if !ok {
74 return time.Time{}, false
75 }
76 d, err := strconv.Atoi(day)
77 if err != nil || d < 1 || d > 31 {
78 return time.Time{}, false
79 }
80 y := now.Year()
81 assumed := year == ""
82 if !assumed {
83 if y, err = strconv.Atoi(year); err != nil {
84 return time.Time{}, false
85 }
86 }
87 t := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
88 // 31 February rolls over into March rather than failing, and a date that
89 // does not exist is not a date.
90 if t.Day() != d {
91 return time.Time{}, false
92 }
93 // A date written without its year in late December means January, and
94 // nothing names a fixture two months behind us as the next one.
95 if assumed && t.Before(now.AddDate(0, -2, 0)) {
96 t = t.AddDate(1, 0, 0)
97 }
98 return t, true
99}
100
101// latestDate is the furthest ahead date named anywhere in the text.
102func latestDate(text string, now time.Time) (time.Time, bool) {
103 var best time.Time
104 for _, d := range datesIn(text, now) {
105 if d.After(best) {
106 best = d
107 }
108 }
109 return best, !best.IsZero()
110}
111
112// earliestFuture is the soonest date today or later, which for this shape is
113// the one being asked for.
114func earliestFuture(text string, now time.Time) (time.Time, bool) {
115 var best time.Time
116 for _, d := range datesIn(text, now) {
117 if d.Before(truncDay(now)) {
118 continue
119 }
120 if best.IsZero() || d.Before(best) {
121 best = d
122 }
123 }
124 return best, !best.IsZero()
125}
126
127// A page stamps itself with the day it was last touched, and that date is not a
128// fixture. It is the one false positive worth spending a regex on, since a
129// schedule page updated this morning would otherwise look like a match today.
130var metaLine = regexp.MustCompile(`(?i)(updated|published|posted|written|reviewed|retrieved|copyright|all rights reserved|subscribe)`)
131
132// fixtureDates is every date on a line that is not a page stamping itself.
133func fixtureDates(text string, now time.Time) []time.Time {
134 var out []time.Time
135 for _, line := range strings.Split(text, "\n") {
136 if metaLine.MatchString(line) {
137 continue
138 }
139 out = append(out, datesIn(line, now)...)
140 }
141 return out
142}
143
144// sooner catches the answer that took a page at its word. A fixture list
145// covering one competition calls its own first match the next match, so a page
146// of league fixtures says 12 September while a page carrying the cup says the
147// 9th, and picking between them is a date comparison across five documents,
148// which is the thing a 4B does worst.
149//
150// It warns rather than rewrites, because the earlier date can legitimately be
151// something else the passage happened to mention.
152func sooner(text string, passages []Passage, now time.Time) []string {
153 lead, ok := earliestFuture(text, now)
154 if !ok {
155 return nil
156 }
157 var best time.Time
158 for _, p := range passages {
159 for _, d := range fixtureDates(p.Text, now) {
160 if d.Before(truncDay(now)) {
161 continue
162 }
163 if best.IsZero() || d.Before(best) {
164 best = d
165 }
166 }
167 }
168 if best.IsZero() || !best.Before(lead) {
169 return nil
170 }
171 return []string{fmt.Sprintf(
172 "a source names %s, which is sooner than the %s this leads with, so check that the earlier one is not a fixture in a competition the other pages leave out",
173 best.Format("2 January"), lead.Format("2 January"))}
174}
175
176// noFutureDate reports whether nothing in the answer is dated today or later,
177// which for this shape means the answer does not contain what was asked for.
178func noFutureDate(text string, now time.Time) bool {
179 d, ok := latestDate(text, now)
180 if !ok {
181 return true
182 }
183 return d.Before(truncDay(now))
184}
185
186// pastOnly is the reader-facing half of the same check.
187func pastOnly(text string, now time.Time) []string {
188 if strings.TrimSpace(text) == "" {
189 return nil
190 }
191 d, ok := latestDate(text, now)
192 if !ok {
193 return []string{"this names no date, and the question asked when something happens next"}
194 }
195 if d.Before(truncDay(now)) {
196 return []string{fmt.Sprintf(
197 "every date here has already passed, the latest is %s, so the pages found cover what has already happened rather than what is next",
198 d.Format("2 January 2006"))}
199 }
200 return nil
201}
202
203// scheduleAge warns when the freshest page behind a schedule is old enough
204// that the fixture has probably moved. The model was asked to say this itself
205// for one round and wrote that its sources were published in late September on
206// the fifth of September, which is the same lesson stale.go opens with: a 4B
207// does not compare dates in prose, so the comparison happens in Go and the
208// answer says nothing about it.
209func scheduleAge(sources []Source, now time.Time) []string {
210 var newest time.Time
211 for _, s := range sources {
212 if t, ok := parseDate(s.Published); ok && t.After(newest) {
213 newest = t
214 }
215 }
216 if newest.IsZero() {
217 return []string{"none of these pages says when it was written, so there is no telling whether this schedule is current"}
218 }
219 if days := int(truncDay(now).Sub(truncDay(newest)).Hours() / 24); days > 14 {
220 return []string{fmt.Sprintf(
221 "the newest page behind this was written %d days ago, and a schedule moves, so check it against the official one", days)}
222 }
223 return nil
224}
225
226// upcomingHint is the retry's instruction. The first search found the right
227// subject and the wrong half of it, so re-planning has to change the words
228// rather than the topic.
229func upcomingHint() string {
230 return "A previous search found only events that have already happened, and the question asks what is next. " +
231 "Write queries for the schedule itself, using words like schedule, fixtures, upcoming or next, " +
232 "and drop any word about a result, a score or what happened."
233}