Merge pull request 'An active research effort takes new versions, reviewed as proposals (hq issue 844)' (#318) from fix/844-an-active-effort-takes-new-versions into main
This commit was merged in pull request #318.
This commit is contained in:
@@ -21,7 +21,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that
|
||||
| `links` | `number`, `kind` | the record's typed links, out and in |
|
||||
| `history` | `number`, `kind`, `from`, `to`, `bound` | every version; with `from` and `to`, the difference between the two, line by line |
|
||||
| `reserve` | `kind`, `for` | the next decision or research number, asked of the issue tracker (`issue-tracker.reserve`); spent whether or not its record is accepted |
|
||||
| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`). Each version proposed is then reviewed by the knowledge base itself (below) |
|
||||
| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`), but an active research effort takes new versions freely, each reviewed as a proposal. Each version proposed is then reviewed by the knowledge base itself (below) |
|
||||
| `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number |
|
||||
| `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number |
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// effortAsk is a research effort proposal with the given documents.
|
||||
func effortAsk(n int, status string, docs ...Section) ProposeAsk {
|
||||
return ProposeAsk{Kind: KindResearch, Number: n, Title: "A vendor-neutral inference layer", Date: "2026-10-11",
|
||||
Deciders: []string{"jochen"}, Status: status, Sections: docs,
|
||||
Links: []LinkAsk{{Kind: LinkTouches, To: "0290"}}}
|
||||
}
|
||||
|
||||
// novox/hq issue 844: an active research effort takes new versions freely, each reviewed as a proposal.
|
||||
func TestAnActiveEffortTakesANewVersionWithANewDocument(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."}
|
||||
w.accepted(t, "author@g14", effortAsk(r.Number, StatusActive, overview))
|
||||
key := Key{KindResearch, r.Number}
|
||||
reviews := len(w.judge.reviews)
|
||||
next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."})
|
||||
next.Why = "findings added"
|
||||
if _, err := w.k.Propose("author@g14", next); err != nil {
|
||||
t.Fatalf("an active effort refused a new version: %v", err)
|
||||
}
|
||||
e := mustGet(t, w.k.Store, key)
|
||||
if e.Pending == nil || e.Current.Section("01-findings.md") != "" {
|
||||
t.Fatalf("the new version was served before its review")
|
||||
}
|
||||
w.reviewAll(t)
|
||||
q := w.judge.reviews[reviews]
|
||||
if strings.Contains(q, "a correction of fact (a progressive insight)") {
|
||||
t.Fatalf("the review judged a new version as an insight:\n%.400s", q)
|
||||
}
|
||||
e = mustGet(t, w.k.Store, key)
|
||||
if e.Pending != nil || e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "A new finding." ||
|
||||
len(e.Current.Insights) != 0 {
|
||||
t.Fatalf("after the review: %s, pending %v, insights %v", e.Current.Status, e.Pending != nil, e.Current.Insights)
|
||||
}
|
||||
h, err := w.k.History(key, 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range h.Versions {
|
||||
if v.Insight {
|
||||
t.Errorf("version %d is marked an insight", v.N)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGraduatedEffortAndADecisionStillTakeOnlyAnInsight(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."}
|
||||
grad := effortAsk(r.Number, StatusGraduated, overview)
|
||||
grad.Links = append(grad.Links, LinkAsk{Kind: LinkBecame, To: "0290"})
|
||||
w.accepted(t, "author@g14", grad)
|
||||
next := effortAsk(r.Number, StatusGraduated, overview, Section{Name: "01-findings.md", Text: "More."})
|
||||
next.Links = grad.Links
|
||||
if _, err := w.k.Propose("author@g14", next); err == nil || !strings.Contains(err.Error(), "progressive insight") {
|
||||
t.Fatalf("a graduated effort took a plain version: %v", err)
|
||||
}
|
||||
n := w.reserved(t, "author@g14")
|
||||
w.accepted(t, "author@g14", valid(n))
|
||||
p := valid(n)
|
||||
p.Sections[2].Text = "Changed."
|
||||
if _, err := w.k.Propose("author@g14", p); err == nil || !strings.Contains(err.Error(), "progressive insight") {
|
||||
t.Fatalf("a decision took a plain version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An old active effort's new text is held to the rules as of the day it is proposed, not the day the effort began.
|
||||
func TestANewVersionOfAnOldActiveEffortIsHeldToTodaysRules(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
w.clock = time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an old effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."}
|
||||
first := effortAsk(r.Number, StatusActive, overview)
|
||||
first.Date = "2026-10-01"
|
||||
w.accepted(t, "author@g14", first)
|
||||
w.clock = time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)
|
||||
next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "The control plane wrote them."})
|
||||
next.Date = "2026-10-01"
|
||||
if _, err := w.k.Propose("author@g14", next); err == nil || !strings.Contains(err.Error(), "retired-word") {
|
||||
t.Fatalf("a retired word in new text of an old effort: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChangesVerdictLeavesTheServedVersionServedAndGraduatingNeedsABecameLink(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."}
|
||||
w.accepted(t, "author@g14", effortAsk(r.Number, StatusActive, overview))
|
||||
key := Key{KindResearch, r.Number}
|
||||
w.judge.review = `{"verdict":"changes","findings":["01-findings.md: say where the finding comes from"]}`
|
||||
next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."})
|
||||
if _, err := w.k.Propose("author@g14", next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
e := mustGet(t, w.k.Store, key)
|
||||
if e.Pending == nil || e.Pending.Reviews[len(e.Pending.Reviews)-1].Verdict != VerdictChanges ||
|
||||
e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "" {
|
||||
t.Fatalf("after changes: pending %v, current %+v", e.Pending != nil, e.Current.Sections)
|
||||
}
|
||||
grad := effortAsk(r.Number, StatusGraduated, overview)
|
||||
if _, err := w.k.Propose("author@g14", grad); err == nil || !strings.Contains(err.Error(), "became") {
|
||||
t.Fatalf("graduated without a became link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An earlier marked insight in an old effort's text does not predate the effort, and a new version is accepted.
|
||||
func TestAnOldActiveEffortWithAMarkedInsightTakesANewVersion(t *testing.T) {
|
||||
w := imported(t, newMemStore())
|
||||
w.clock = time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
|
||||
r, err := w.k.ReserveNumber("author@g14", KindResearch, "an old effort")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overview := Section{Name: "00-overview.md", Text: "What an inference layer would be.\n\n**Progressive insight — 2026-10-05.** A fact was corrected."}
|
||||
first := effortAsk(r.Number, StatusActive, overview)
|
||||
first.Date = "2026-10-01"
|
||||
w.accepted(t, "author@g14", first)
|
||||
w.clock = time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)
|
||||
next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."})
|
||||
next.Date = "2026-10-01"
|
||||
if _, err := w.k.Propose("author@g14", next); err != nil {
|
||||
t.Fatalf("a new version with an earlier marked insight: %v", err)
|
||||
}
|
||||
w.reviewAll(t)
|
||||
e := mustGet(t, w.k.Store, Key{KindResearch, r.Number})
|
||||
if e.Pending != nil || e.Current.Section("01-findings.md") != "A new finding." || e.Current.Date != "2026-10-01" {
|
||||
t.Fatalf("after the review: pending %v, date %s", e.Pending != nil, e.Current.Date)
|
||||
}
|
||||
}
|
||||
@@ -357,7 +357,7 @@ func (k *KB) Get(a GetAsk) (*Got, error) {
|
||||
g.Reserved = fmt.Sprintf("reserved by %s at %s for %q", r.ReservedBy, r.ReservedAt.Format("2006-01-02 15:04 UTC"), r.ReservedFor)
|
||||
}
|
||||
if e.Pending != nil && a.Version == 0 {
|
||||
g.Pending = fmt.Sprintf("version %d, a progressive insight, waits for its review: history shows what it changes", e.PendingVersion)
|
||||
g.Pending = fmt.Sprintf("version %d, a progressive insight or a new version of an active effort, waits for its review: history shows what it changes", e.PendingVersion)
|
||||
}
|
||||
if r.Ask != nil {
|
||||
g.Waiting = "the operator's ask: " + r.Ask.Said
|
||||
|
||||
@@ -335,7 +335,10 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
|
||||
return nil, fmt.Errorf("%s has a progressive insight by %s waiting for its review: only its author, proven by the "+
|
||||
"runtime, revises it (this call is by %s). Nothing was proposed", key, held.Pending.Author, caller)
|
||||
}
|
||||
if strings.TrimSpace(a.Insight) == "" {
|
||||
// An active research effort is still being researched: it takes new versions freely, each reviewed as a
|
||||
// proposal while the served version stays (novox/hq issue 844). Every other accepted record needs an insight.
|
||||
revision := a.Kind == KindResearch && held.Current.Status == StatusActive && strings.TrimSpace(a.Insight) == ""
|
||||
if strings.TrimSpace(a.Insight) == "" && !revision {
|
||||
return nil, fmt.Errorf("%s is %s: it changes only by a new record, or by a progressive insight, a correction of fact "+
|
||||
"that leaves the decision standing (give insight: what stood before). Nothing was proposed", key, held.Current.Status)
|
||||
}
|
||||
@@ -349,8 +352,14 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
|
||||
r.Pending = &PendingState{Insight: Insight{Date: k.now().Format("2006-01-02"), Before: strings.TrimSpace(a.Insight), By: caller}}
|
||||
r.Date = held.Current.Date
|
||||
r.Effort = a.Status
|
||||
if revision {
|
||||
r.Pending = &PendingState{}
|
||||
}
|
||||
if why == "" {
|
||||
why = "a progressive insight proposed"
|
||||
if revision {
|
||||
why = "a new version proposed"
|
||||
}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%s is %s and takes no proposal; nothing was proposed", key, held.Working().Status)
|
||||
@@ -370,7 +379,13 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
|
||||
if !current && a.Kind == KindResearch && r.Effort == "" {
|
||||
r.Effort = held.Current.Status
|
||||
}
|
||||
refusals = append(refusals, JudgeRecord(r, held, k.lookup(ctx), ref)...)
|
||||
// A new version of an active effort is new text: the dated rules hold it from the day it is proposed, while the
|
||||
// record keeps its own date (novox/hq issue 844).
|
||||
day := r.Date
|
||||
if r.Pending != nil && !r.IsInsight() {
|
||||
day = k.now().Format("2006-01-02")
|
||||
}
|
||||
refusals = append(refusals, JudgeRecordOn(r, held, k.lookup(ctx), ref, day)...)
|
||||
if raw, _ := json.Marshal(r); len(raw) > MostRecordBytes {
|
||||
refusals = append(refusals, Refusal{Rule: "size", Says: fmt.Sprintf("the record is %d bytes; a record holds at most %d", len(raw), MostRecordBytes)})
|
||||
}
|
||||
@@ -381,7 +396,7 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
|
||||
}
|
||||
return nil, fmt.Errorf("%s is refused by %d rule(s), and nothing was written:\n- %s", key, len(refusals), strings.Join(lines, "\n- "))
|
||||
}
|
||||
v := Version{By: caller, At: k.now(), Why: why, Record: r, Insight: !current}
|
||||
v := Version{By: caller, At: k.now(), Why: why, Record: r, Insight: r.IsInsight()}
|
||||
if err := k.Store.Save(ctx, v, current, false, held.Revision); err != nil {
|
||||
return nil, fmt.Errorf("%s could not be written (%v); nothing was proposed", key, err)
|
||||
}
|
||||
@@ -397,7 +412,10 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) {
|
||||
if r.NeedsOperator {
|
||||
says += ", and then the operator's Approve"
|
||||
}
|
||||
if !current {
|
||||
if !current && !r.IsInsight() {
|
||||
says = fmt.Sprintf("A new version of %s is proposed as version %d. The knowledge base reviews it now; the served version stays "+
|
||||
"until that review approves it", key, n)
|
||||
} else if !current {
|
||||
says = fmt.Sprintf("A progressive insight to %s is proposed as version %d. The knowledge base reviews it now; the accepted "+
|
||||
"version is served until that review approves it", key, n)
|
||||
}
|
||||
@@ -416,8 +434,11 @@ func (k *KB) accept(ctx context.Context, held *Entry, w *Record, by, why, warran
|
||||
at := k.now()
|
||||
if insight {
|
||||
in := r.Pending.Insight
|
||||
isInsight := r.IsInsight()
|
||||
r.Pending = nil
|
||||
r.Insights = append(r.Insights, in)
|
||||
if isInsight {
|
||||
r.Insights = append(r.Insights, in)
|
||||
}
|
||||
r.Status = held.Current.Status
|
||||
if r.Kind == KindResearch && contains(ResearchStatuses, r.Effort) && held.Current.Status != StatusSuperseded {
|
||||
r.Status = r.Effort
|
||||
@@ -442,7 +463,7 @@ func (k *KB) accept(ctx context.Context, held *Entry, w *Record, by, why, warran
|
||||
if warrant != "" {
|
||||
why += "; " + warrant
|
||||
}
|
||||
if err := k.Store.Save(ctx, Version{By: by, At: at, Why: why, Record: r, Insight: insight}, true, true, held.Revision); err != nil {
|
||||
if err := k.Store.Save(ctx, Version{By: by, At: at, Why: why, Record: r, Insight: insight && w.IsInsight()}, true, true, held.Revision); err != nil {
|
||||
return nil, fmt.Errorf("%s could not be accepted (%v); it stays proposed", r.Key(), err)
|
||||
}
|
||||
k.emit(EventAccepted, r, by, why, held.Versions+1)
|
||||
|
||||
@@ -917,7 +917,7 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
"links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator, with the brief its ask shows. Refused, naming " +
|
||||
"every reason at once and writing nothing, when it breaks one of the 30 rules that judge one record or its prose — a " +
|
||||
"link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " +
|
||||
"proposal is revised by proposing it again; an accepted record changes only by a progressive insight (insight: what " +
|
||||
"proposal is revised by proposing it again; an accepted record (an active research effort excepted, which takes new versions) changes only by a progressive insight (insight: what " +
|
||||
"stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " +
|
||||
"records it links and the rules. A decision that names no home and that no accepted record links is not judged: " +
|
||||
"its review asks changes for the whole-set rule cited, which its acceptance would break. On approve it is accepted (a record that needs the operator waits for the operator's " +
|
||||
|
||||
@@ -192,7 +192,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
|
||||
if err != nil || e == nil {
|
||||
return "", fmt.Errorf("%s: %s, and that could not be kept (%v)", w.Key(), said, err)
|
||||
}
|
||||
if err := k.Store.Save(ctx, Version{By: ReviewBy, At: k.now(), Why: said, Record: w, Insight: e.Pending != nil},
|
||||
if err := k.Store.Save(ctx, Version{By: ReviewBy, At: k.now(), Why: said, Record: w, Insight: e.Pending != nil && e.Pending.IsInsight()},
|
||||
e.Pending == nil, false, e.Revision); err != nil {
|
||||
return "", fmt.Errorf("%s: %s, and that could not be kept (%v)", w.Key(), said, err)
|
||||
}
|
||||
@@ -208,7 +208,7 @@ func (k *KB) askOperator(ctx context.Context, held *Entry, w *Record, by string,
|
||||
return unasked(fmt.Sprintf("the ask could not be made (%v)", err))
|
||||
}
|
||||
w.Ask = oa
|
||||
if err := k.Store.Save(ctx, Version{By: by, At: k.now(), Why: "reviewed: approved; the operator is asked", Record: w, Insight: held.Pending != nil},
|
||||
if err := k.Store.Save(ctx, Version{By: by, At: k.now(), Why: "reviewed: approved; the operator is asked", Record: w, Insight: held.Pending != nil && held.Pending.IsInsight()},
|
||||
held.Pending == nil, false, held.Revision); err != nil {
|
||||
return "", fmt.Errorf("%s: the review could not be kept (%v); the operator was not asked", w.Key(), err)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func (k *KB) Reconcile() ([]string, error) {
|
||||
func (k *KB) endAsk(ctx context.Context, e *Entry, w *Record, why string) string {
|
||||
w = w.Clone()
|
||||
w.Ask.Said = why
|
||||
if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: "the operator's ask " + why, Record: w, Insight: e.Pending != nil},
|
||||
if err := k.Store.Save(ctx, Version{By: "the knowledge base", At: k.now(), Why: "the operator's ask " + why, Record: w, Insight: e.Pending != nil && e.Pending.IsInsight()},
|
||||
e.Pending == nil, false, e.Revision); err != nil {
|
||||
return fmt.Sprintf("%s: the end of its ask could not be kept (%v)", w.Key(), err)
|
||||
}
|
||||
|
||||
@@ -360,6 +360,10 @@ type PendingState struct {
|
||||
Insight Insight `json:"insight"`
|
||||
}
|
||||
|
||||
// IsInsight says the record is waiting as a progressive insight, a correction of fact that says what stood before.
|
||||
// A pending version without one is a new version of an active research effort (novox/hq issue 844).
|
||||
func (r *Record) IsInsight() bool { return r.Pending != nil && r.Pending.Insight.Before != "" }
|
||||
|
||||
// Key is the record's key.
|
||||
func (r *Record) Key() Key { return Key{r.Kind, r.Number} }
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ var reviewSchema = map[string]any{
|
||||
func ReviewQuestion(r *Record, linked []*Record) string {
|
||||
var b strings.Builder
|
||||
what := "a proposed " + r.Kind + " record"
|
||||
if r.Pending != nil {
|
||||
if r.IsInsight() {
|
||||
what = "a correction of fact (a progressive insight) proposed to an accepted " + r.Kind + " record; what stood before: " + r.Pending.Insight.Before
|
||||
}
|
||||
fmt.Fprintf(&b, "You review %s of the mesh's knowledge base, %s. You see only the record, the records it links, and the "+
|
||||
@@ -475,7 +475,7 @@ func (k *KB) reviewOne(key Key) (string, error) {
|
||||
version = now.PendingVersion
|
||||
}
|
||||
save := func(r *Record, why string) error {
|
||||
return k.Store.Save(ctx, Version{By: by, At: k.now(), Why: why, Record: r, Insight: now.Pending != nil},
|
||||
return k.Store.Save(ctx, Version{By: by, At: k.now(), Why: why, Record: r, Insight: now.Pending != nil && now.Pending.IsInsight()},
|
||||
now.Pending == nil, false, now.Revision)
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
@@ -123,9 +123,16 @@ func allText(r *Record) string {
|
||||
// JudgeRecord holds a proposed record to the 30 rules. held is the entry its number holds (a reservation, the
|
||||
// proposal being revised, or the accepted record an insight corrects); lookup reads every other record.
|
||||
func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal {
|
||||
return JudgeRecordOn(r, held, lookup, ref, r.Date)
|
||||
}
|
||||
|
||||
// JudgeRecordOn is JudgeRecord with the day the dated rules are held from: the record's own day for a new
|
||||
// record, the day of the proposal for a new version of an active research effort (novox/hq issue 844). The rules
|
||||
// that read the record's date (the date and insight-form rules) keep reading r.Date.
|
||||
func JudgeRecordOn(r *Record, held *Entry, lookup Lookup, ref *Reference, day string) []Refusal {
|
||||
var out []Refusal
|
||||
refuse := func(rule, format string, a ...any) {
|
||||
if Holds(rule, r.Kind, r.Date) {
|
||||
if Holds(rule, r.Kind, day) {
|
||||
out = append(out, Refusal{Rule: rule, Says: fmt.Sprintf(format, a...)})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user