notegraph 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: notegraph
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Decision-graph and goal notebook for a git repo, shared across every worktree
5
5
  Author-email: 송재훈 <sjh030504@i-screammedia.com>
6
6
  License: MIT
@@ -37,15 +37,20 @@ to agents working other branches in parallel.
37
37
  - **Only criteria named in the current goal are accepted.** If a rubric no
38
38
  longer fits the work, change the goal (`note goal --set ... --criterion ...`)
39
39
  rather than inventing a criterion at score time.
40
- - **Totals are recomputed against the current goal.** When the goal moves, old
41
- attempts visibly drop until they are re-judged. That is correct.
40
+ - **Totals are recomputed against the current goal**, so when the goal moves,
41
+ every earlier score is marked `STALE` and `note check` fails until you
42
+ re-score. A stale total is arithmetic, not judgement — do not compare arms
43
+ on one. `note goal --set` tells you how many scores it just invalidated.
42
44
 
43
45
  ## Correcting a node
44
46
 
45
47
  Nothing is ever edited in place. `note supersede <id> "corrected content"`
46
48
  appends a new node and an edge `old -superseded_by-> new`, which moves the
47
49
  frontier to the new node and takes the old one out of `note tails`. Type and
48
- tags are inherited unless you pass new ones.
50
+ tags are inherited unless you pass new ones, along with whatever goal the
51
+ original targeted — so the correction is already linked and only needs a
52
+ score. The node you corrected stops competing for best and stops being asked
53
+ for one.
49
54
 
50
55
  ## Orientation
51
56
 
@@ -78,8 +78,40 @@ def main_check():
78
78
  after = {n["id"] for n in graphtails()}
79
79
  assert 5 not in after and 6 in after, (before, after)
80
80
 
81
+ # supersede carries the targets edge forward, so the correction is already
82
+ # linked and only needs judging
83
+ from note_cli import store as _st
84
+ assert {"from": 6, "to": 1, "relation": "targets"} in _st.read("edges.jsonl")
85
+ run("score", "6", "consistency=0.5")
86
+
87
+ # and the node it corrected must no longer compete for best
88
+ from note_cli import goal as _g
89
+ rows, _unassigned = _g.rollup()
90
+ winners = [b[1] for _n, b in rows if b]
91
+ assert 5 not in winners, f"superseded #5 still winning: {winners}"
92
+
81
93
  run("status")
82
94
 
95
+ # the bug this feature exists for: moving the goal must not leave a
96
+ # confident-looking number with `check` calling it clean
97
+ import time
98
+ time.sleep(1) # timestamps are second-resolution
99
+ run("goal", "--set", "moved the bar",
100
+ "--criterion", "consistency:3:style holds",
101
+ "--criterion", "renamed:1:was legibility")
102
+ try:
103
+ run("check")
104
+ raise AssertionError("expected SystemExit(1): every score is now stale")
105
+ except SystemExit as e:
106
+ assert e.code == 1, e.code
107
+ run("goals") # must mark the stale rows, not just print
108
+ run("show", "3")
109
+
110
+ for nid in ("2", "3", "4", "5", "6"):
111
+ run("score", nid, "consistency=0.5", "renamed=0.5")
112
+ run("check") # re-scored under the new goal: clean again
113
+
114
+
83
115
  # --version must report what the package metadata says, so a bug report
84
116
  # names a real release
85
117
  from note_cli import __version__
@@ -50,8 +50,13 @@ def cmd_goal(args):
50
50
  criteria = [goalmod.parse_criterion(c) for c in (args.criterion or [])]
51
51
  if not criteria:
52
52
  raise SystemExit("--set needs at least one --criterion name:weight:rubric")
53
+ was_fresh = [s for s in goalmod.latest_scores().values()
54
+ if not goalmod.is_stale(s, goalmod.current_goal())]
53
55
  goalmod.set_goal(args.set, criteria)
54
56
  print(f"goal set, {len(criteria)} criteria")
57
+ if was_fresh:
58
+ print(f"{len(was_fresh)} score(s) now judged against an older goal — "
59
+ f"`note check` lists them")
55
60
  return
56
61
  g = goalmod.current_goal()
57
62
  if not g:
@@ -87,18 +92,26 @@ def cmd_score(args):
87
92
  print(f"{goalmod.add_score(args.id, scores, args.note):.3f}")
88
93
 
89
94
 
95
+ def _stale_mark(score, g):
96
+ return " STALE — judged against an older goal" if goalmod.is_stale(score, g) else ""
97
+
98
+
90
99
  def cmd_goals(args):
91
100
  rows, unassigned = goalmod.rollup()
101
+ g = goalmod.current_goal()
92
102
  for n, best in rows:
93
103
  if best:
94
- total, aid, ts = best
95
- print(f"{n['id']:>4} {n['content']}\n best {total:.3f} from #{aid} ({ts})")
104
+ total, aid, score = best
105
+ mark = _stale_mark(score, g)
106
+ print(f"{n['id']:>4} {n['content']}\n"
107
+ f" best {total:.3f} from #{aid} ({score['timestamp']}){mark}")
96
108
  else:
97
109
  print(f"{n['id']:>4} {n['content']}\n (no scored attempts)")
98
110
  if unassigned:
99
111
  print("\n(unassigned) — scored but targeting no goal:")
100
- for n, total, ts in unassigned:
101
- print(f"{n['id']:>4} {total:.3f} {n['content']} ({ts})")
112
+ for n, total, score in unassigned:
113
+ print(f"{n['id']:>4} {total:.3f} {n['content']} "
114
+ f"({score['timestamp']}){_stale_mark(score, g)}")
102
115
 
103
116
 
104
117
  def cmd_supersede(args):
@@ -109,9 +122,9 @@ def cmd_supersede(args):
109
122
  def cmd_check(args):
110
123
  """Exit nonzero while any attempt is loose, so a hook or an agent can gate
111
124
  on it rather than trusting itself to remember the loop."""
112
- untargeted, unscored = goalmod.unfinished()
113
- if not untargeted and not unscored:
114
- print("clean: every attempt targets a goal and carries a score")
125
+ untargeted, unscored, stale = goalmod.unfinished()
126
+ if not untargeted and not unscored and not stale:
127
+ print("clean: every attempt targets a goal and is scored against the current one")
115
128
  return
116
129
  if untargeted:
117
130
  print("attempts targeting no goal — `note link <id> <goal> --rel targets`:")
@@ -121,6 +134,11 @@ def cmd_check(args):
121
134
  print("attempts with no score — `note score <id> name=0.0-1.0`:")
122
135
  for n in unscored:
123
136
  print(_line(n))
137
+ if stale:
138
+ print("attempts judged against an older goal — their totals are "
139
+ "arithmetic, not judgement. Re-score them:")
140
+ for n in stale:
141
+ print(_line(n))
124
142
  raise SystemExit(1)
125
143
 
126
144
 
@@ -152,7 +170,8 @@ def cmd_show(args):
152
170
  if s:
153
171
  g = goalmod.current_goal()
154
172
  total = goalmod.weighted_total(s["scores"], g["criteria"] if g else [])
155
- print(f" score: {total:.3f} {s['scores']} ({s['timestamp']})")
173
+ print(f" score: {total:.3f} {s['scores']} ({s['timestamp']})"
174
+ f"{_stale_mark(s, g)}")
156
175
  if s.get("note"):
157
176
  print(f" {s['note']}")
158
177
  if args.id in {m["id"] for m in graphmod.merges(nodes, edges)}:
@@ -21,6 +21,39 @@ def set_goal(text, criteria):
21
21
  "timestamp": store.now()})
22
22
 
23
23
 
24
+ SUPERSEDED = "superseded_by"
25
+
26
+
27
+ def superseded_ids(edges):
28
+ """Nodes that have been corrected by a later one.
29
+
30
+ Append-only never removes anything, so a superseded node keeps its goal
31
+ link and its score. Both the rollup and the loop check have to skip them:
32
+ otherwise a corrected-away attempt goes on competing for best, and a goal
33
+ revision makes the check demand that it be re-judged.
34
+ """
35
+ return {e["from"] for e in edges if e["relation"] == SUPERSEDED}
36
+
37
+
38
+ def goal_revision(g):
39
+ """Identity of a goal revision. `goal.jsonl` is append-only and written
40
+ under the lock, so its timestamp identifies the revision."""
41
+ return g["timestamp"] if g else None
42
+
43
+
44
+ def is_stale(score, g):
45
+ """True when this score was judged against a different goal revision than
46
+ the current one.
47
+
48
+ Its total is still computed — against today's criteria — which is exactly
49
+ why it has to be flagged: the number looks like a judgement and is
50
+ arithmetic. A score line written before this field existed has no
51
+ `goal_ts`, so it reads stale, which is the honest answer: we do not know
52
+ what standard it was made against.
53
+ """
54
+ return score.get("goal_ts") != goal_revision(g)
55
+
56
+
24
57
  def parse_criterion(s):
25
58
  parts = s.split(":", 2) # first two colons only; rubrics contain colons
26
59
  if len(parts) != 3:
@@ -69,6 +102,7 @@ def add_score(nid, scores, note=None):
69
102
  if unknown:
70
103
  raise SystemExit(f"unknown criteria {unknown}; goal has {sorted(known)}")
71
104
  store.append(SCORE_FILE, {"node": nid, "scores": scores, "note": note,
105
+ "goal_ts": goal_revision(g),
72
106
  "timestamp": store.now()})
73
107
  return weighted_total(scores, g["criteria"])
74
108
 
@@ -86,6 +120,7 @@ def rollup():
86
120
  latest = latest_scores()
87
121
  by_id = {n["id"]: n for n in nodes}
88
122
 
123
+ corrected = superseded_ids(edges)
89
124
  targeted, assigned = {}, set()
90
125
  for e in edges:
91
126
  if e["relation"] == "targets":
@@ -98,10 +133,10 @@ def rollup():
98
133
  continue
99
134
  best = None
100
135
  for aid in targeted.get(n["id"], ()):
101
- if aid not in latest:
136
+ if aid not in latest or aid in corrected:
102
137
  continue
103
138
  cand = (weighted_total(latest[aid]["scores"], criteria), aid,
104
- latest[aid]["timestamp"])
139
+ latest[aid])
105
140
  if best is None or cand[0] > best[0]:
106
141
  best = cand
107
142
  rows.append((n, best))
@@ -109,31 +144,40 @@ def rollup():
109
144
  # a scored attempt with no goal is a linking mistake, not a category of
110
145
  # work — it must not simply vanish from the rollup
111
146
  unassigned = [(by_id[i], weighted_total(latest[i]["scores"], criteria),
112
- latest[i]["timestamp"])
113
- for i in sorted(latest) if i not in assigned and i in by_id]
147
+ latest[i])
148
+ for i in sorted(latest)
149
+ if i not in assigned and i in by_id and i not in corrected]
114
150
  return rows, unassigned
115
151
 
116
152
 
117
153
  def unfinished():
118
- """Attempts that break the loop, as (untargeted, unscored).
154
+ """Attempts that break the loop, as (untargeted, unscored, stale).
119
155
 
120
156
  Only `attempt` nodes are held to it — a decision or a plain note has
121
157
  nothing to score. This is what `note check` exits nonzero on, so a hook
122
158
  or an agent can gate on it instead of trusting itself to remember.
159
+
160
+ `stale` is the one that used to pass silently: the attempt is linked and
161
+ scored, but judged against a goal revision that is no longer current, so
162
+ its total is arithmetic rather than judgement.
123
163
  """
124
164
  nodes = store.read("nodes.jsonl")
125
165
  edges = store.read("edges.jsonl")
126
166
  latest = latest_scores()
167
+ g = current_goal()
127
168
  targeting = {e["from"] for e in edges if e["relation"] == "targets"}
128
- untargeted, unscored = [], []
169
+ corrected = superseded_ids(edges)
170
+ untargeted, unscored, stale = [], [], []
129
171
  for n in nodes:
130
- if n["type"] != "attempt":
172
+ if n["type"] != "attempt" or n["id"] in corrected:
131
173
  continue
132
174
  if n["id"] not in targeting:
133
175
  untargeted.append(n)
134
176
  elif n["id"] not in latest:
135
177
  unscored.append(n)
136
- return untargeted, unscored
178
+ elif is_stale(latest[n["id"]], g):
179
+ stale.append(n)
180
+ return untargeted, unscored, stale
137
181
 
138
182
 
139
183
  def demo():
@@ -196,15 +240,54 @@ def demo():
196
240
  assert [u[0]["id"] for u in unassigned] == [orphan], unassigned
197
241
 
198
242
  # orphan targets nothing; a1/a2 target g and are scored
199
- untargeted, unscored = unfinished()
243
+ untargeted, unscored, stale = unfinished()
200
244
  assert [n["id"] for n in untargeted] == [orphan], untargeted
201
- assert unscored == [], unscored
245
+ assert unscored == [] and stale == [], (unscored, stale)
202
246
 
203
247
  fresh = store.add_node("just tried, not judged yet", type="attempt")
204
248
  store.add_edge(fresh, g, "targets")
205
- untargeted, unscored = unfinished()
249
+ untargeted, unscored, stale = unfinished()
206
250
  assert [n["id"] for n in untargeted] == [orphan], untargeted
207
251
  assert [n["id"] for n in unscored] == [fresh], unscored
252
+ assert stale == [], stale
253
+
254
+ # a score carries the goal revision it was judged against
255
+ assert latest_scores()[a1]["goal_ts"] == current_goal()["timestamp"]
256
+ assert not is_stale(latest_scores()[a1], current_goal())
257
+
258
+ # renaming a criterion must NOT silently leave a confident-looking number
259
+ import time
260
+ time.sleep(1) # timestamps are second-resolution
261
+ set_goal("moved the bar", [{"name": "consistency", "weight": 3.0, "rubric": "x"},
262
+ {"name": "renamed", "weight": 1.0, "rubric": "y"}])
263
+ assert is_stale(latest_scores()[a1], current_goal())
264
+ untargeted, unscored, stale = unfinished()
265
+ assert [n["id"] for n in stale] == [a1, a2], stale # orphan is untargeted, fresh unscored
266
+
267
+ # a re-score under the new goal clears it
268
+ add_score(a1, {"consistency": 1.0})
269
+ assert not is_stale(latest_scores()[a1], current_goal())
270
+ _, _, stale = unfinished()
271
+ assert [n["id"] for n in stale] == [a2], stale
272
+
273
+ # a score line predating this feature has no goal_ts and must read stale
274
+ assert is_stale({"scores": {}}, current_goal())
275
+
276
+ # --- a corrected attempt must stop competing ---
277
+ add_score(a2, {"consistency": 1.0, "renamed": 1.0}) # a2 is now the best
278
+ rows, _ = rollup()
279
+ assert rows[0][1][1] == a2, rows[0]
280
+
281
+ fixed = store.supersede(a2, "arm B1_P2, corrected")
282
+ rows, _ = rollup()
283
+ best_id = rows[0][1][1] if rows[0][1] else None
284
+ assert best_id != a2, "a superseded attempt must not win the rollup"
285
+ assert best_id == a1, (best_id, a1) # fixed is unscored, so a1 wins
286
+
287
+ # and it must not be demanded back by the loop check either
288
+ _, unscored, stale = unfinished()
289
+ assert a2 not in [n["id"] for n in unscored] + [n["id"] for n in stale]
290
+ assert fixed in [n["id"] for n in unscored], "the correction still needs judging"
208
291
 
209
292
  print("goal: ok")
210
293
 
@@ -138,6 +138,14 @@ def supersede(old, content, type=None, tags=()):
138
138
  "timestamp": now(),
139
139
  })
140
140
  append("edges.jsonl", {"from": old, "to": nid, "relation": "superseded_by"})
141
+ # Carry the goal links forward. Without this the correction targets
142
+ # nothing while the corrected node keeps its link AND its score, so
143
+ # `note goals` can report an attempt that was corrected away as the
144
+ # best one — silently.
145
+ for e in read("edges.jsonl"):
146
+ if e["from"] == old and e["relation"] == "targets":
147
+ append("edges.jsonl", {"from": nid, "to": e["to"],
148
+ "relation": "targets"})
141
149
  return nid
142
150
 
143
151
 
@@ -198,6 +206,9 @@ def demo():
198
206
  assert rows[new_id]["type"] == rows[a]["type"], "type is inherited"
199
207
  assert rows[new_id]["tags"] == rows[a]["tags"], "tags are inherited"
200
208
  assert {"from": a, "to": new_id, "relation": "superseded_by"} in read("edges.jsonl")
209
+ # the correction must inherit what the original targeted, or the rollup
210
+ # keeps crediting the node that was corrected away
211
+ assert {"from": new_id, "to": g, "relation": "targets"} in read("edges.jsonl")
201
212
  try:
202
213
  supersede(999, "nope")
203
214
  raise AssertionError("expected SystemExit")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: notegraph
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Decision-graph and goal notebook for a git repo, shared across every worktree
5
5
  Author-email: 송재훈 <sjh030504@i-screammedia.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "notegraph"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Decision-graph and goal notebook for a git repo, shared across every worktree"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
File without changes
File without changes
File without changes
File without changes
File without changes