feat(kbblock): fade-out per §9 #3 — seed + RenderFaded + Tick + Refresh

Phase 4b. Wires fade-out for HarborForge kb-block entries using the
new Plexum-sdk-go/fade primitives + RenderDynamicSubblockRequest's
CurrentTurn field.

  internal/kbblock/kbblock.go:
    - Entry gains Seed int64 (json:"seed") for per-entry RNG seed
    - Add() generates a fresh Seed at insert time
    - renderLocked() refactored — Render() unchanged behaviour;
      RenderFaded(currentTurn, params) added — applies sdkfade.Fade
      per entry given (currentTurn - LastRefreshAtTurn) elapsed
    - Tick(currentTurn, params) drops entries whose underscore ratio
      crossed the m% threshold, returns dropped IDs
    - Refresh(ids, currentTurn) resets fade state (regenerates Seed
      + bumps LastRefreshAtTurn) — exposed for a future
      dynamic-kb-refresh tool (not in this commit)

  cmd/plexum-harborforge-plugin/main.go:
    - RenderDynamicSubblock signature now sdkplugin.
      RenderDynamicSubblockRequest (per SDK d6fdb9f)
    - Each turn: Tick → drop crossed-threshold entries (logs IDs
      dropped) → Save (only when something dropped) → RenderFaded
      returned. Uses sdkfade.DefaultFadeParams() (5/10/70 per §9 #3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-06-06 00:26:04 +01:00
parent e801b719a1
commit 9e24c52ae5
2 changed files with 122 additions and 16 deletions

View File

@@ -28,11 +28,15 @@ package kbblock
import (
"encoding/json"
"fmt"
"math/rand"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
sdkfade "git.hangman-lab.top/hzhang/Plexum-sdk-go/fade"
)
const FileName = "kb-block.json"
@@ -47,6 +51,10 @@ type Entry struct {
InsertSeq int `json:"insert_seq"` // per-session monotonic, for render order
AddedAtTurn int `json:"added_at_turn"`
LastRefreshAtTurn int `json:"last_refresh_at_turn"`
// Seed is the per-entry RNG seed for deterministic fade-out
// (DESIGN-DYNAMIC-BLOCK.md §9 #3 / Plexum decision #30). Populated
// at Add() time; reproducible across restarts.
Seed int64 `json:"seed"`
}
// Block is the in-memory representation of kb-block.json.
@@ -138,6 +146,7 @@ func (b *Block) Add(id int, kbCode, sourceTopic, content string, atTurn int) *En
InsertSeq: b.NextSeq,
AddedAtTurn: atTurn,
LastRefreshAtTurn: atTurn,
Seed: newSeed(),
}
b.NextSeq++
b.Entries = append(b.Entries, e)
@@ -223,13 +232,30 @@ func (b *Block) Save() error {
// Render returns the inner body of the <kb-block> subblock — a flat
// sequence of `<kb-fact id=N kb=<code> source=topic:<slug>>\n<content>
// \n</kb-fact>` separated by single blank lines, ordered by InsertSeq
// (DESIGN-DYNAMIC-BLOCK.md §9 #4). Empty block returns "".
// (DESIGN-DYNAMIC-BLOCK.md §9 #4). Empty block returns "". No fade
// applied; use RenderFaded.
//
// The plugin's DynamicBlockProvider.RenderDynamicSubblock returns this
// to the Plexum host, which wraps in <kb-block>...</kb-block>.
func (b *Block) Render() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.renderLocked(nil)
}
// RenderFaded returns the body with sdkfade.Fade applied per entry
// given currentTurn + params (DESIGN-DYNAMIC-BLOCK.md §9 #3). The
// block itself is NOT mutated by render; pair with Tick to actually
// drop entries that crossed the m% drop threshold.
func (b *Block) RenderFaded(currentTurn int, params sdkfade.FadeParams) string {
b.mu.Lock()
defer b.mu.Unlock()
return b.renderLocked(func(e *Entry) string {
return sdkfade.Fade(e.Content, e.Seed, currentTurn-e.LastRefreshAtTurn, params).Rendered
})
}
func (b *Block) renderLocked(transform func(*Entry) string) string {
if len(b.Entries) == 0 {
return ""
}
@@ -241,16 +267,81 @@ func (b *Block) Render() string {
if i > 0 {
sb.WriteByte('\n')
}
content := e.Content
if transform != nil {
content = transform(e)
}
fmt.Fprintf(&sb, "<kb-fact id=%d kb=%s", e.ID, e.KBCode)
if e.SourceTopic != "" {
fmt.Fprintf(&sb, " source=topic:%s", e.SourceTopic)
}
sb.WriteString(">\n")
sb.WriteString(e.Content)
if !strings.HasSuffix(e.Content, "\n") {
sb.WriteString(content)
if !strings.HasSuffix(content, "\n") {
sb.WriteByte('\n')
}
sb.WriteString("</kb-fact>\n")
}
return sb.String()
}
// TickResult summarizes one fade tick across the whole block.
type TickResult struct {
FadedOut []int // entry IDs auto-removed because fade crossed threshold
}
// Tick advances fade by computing each entry's current rendered form
// at currentTurn and DROPPING entries whose underscore ratio crosses
// the m% threshold. Returns the IDs dropped so caller can log/report.
// Caller must Save() after Tick.
func (b *Block) Tick(currentTurn int, params sdkfade.FadeParams) TickResult {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.Entries) == 0 {
return TickResult{}
}
var faded []int
kept := b.Entries[:0]
for _, e := range b.Entries {
d := currentTurn - e.LastRefreshAtTurn
fr := sdkfade.Fade(e.Content, e.Seed, d, params)
if fr.ShouldDrop(params) {
faded = append(faded, e.ID)
continue
}
kept = append(kept, e)
}
b.Entries = kept
return TickResult{FadedOut: faded}
}
// Refresh resets fade state for the given IDs (regenerates Seed +
// sets LastRefreshAtTurn = currentTurn). Returns IDs actually
// refreshed.
func (b *Block) Refresh(ids []int, currentTurn int) []int {
b.mu.Lock()
defer b.mu.Unlock()
want := map[int]bool{}
for _, id := range ids {
want[id] = true
}
var refreshed []int
for _, e := range b.Entries {
if want[e.ID] {
e.LastRefreshAtTurn = currentTurn
e.Seed = newSeed()
refreshed = append(refreshed, e.ID)
}
}
return refreshed
}
// newSeed generates a fresh int64 seed for fade RNG.
var seedRng = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec
var seedMu sync.Mutex
func newSeed() int64 {
seedMu.Lock()
defer seedMu.Unlock()
return seedRng.Int63()
}