From 8583552f1c0ce8a1c21d1ce2a502fee8105098a0 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 01:38:12 +0300 Subject: [PATCH 001/121] feat(svcauth): the articles:contribute scope, OIDC only, budgeted like a write scope --- internal/svcauth/config.go | 13 ++- internal/svcauth/contribute_test.go | 153 ++++++++++++++++++++++++++++ internal/v1/policy.go | 4 +- internal/v1/policy_test.go | 5 +- internal/v1/principal.go | 21 ++-- internal/v1/principal_test.go | 19 ++-- 6 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 internal/svcauth/contribute_test.go diff --git a/internal/svcauth/config.go b/internal/svcauth/config.go index f4dbd82..42133ac 100644 --- a/internal/svcauth/config.go +++ b/internal/svcauth/config.go @@ -77,9 +77,11 @@ type KeyConfig struct { CreatedAt string } -// Writer reports whether the principal holds a write or stub scope. +// Writer reports whether the principal holds a write, stub or contribute scope. func (p PrincipalConfig) Writer() bool { - return slices.Contains(p.Scopes, v1.ScopeArticlesWrite) || slices.Contains(p.Scopes, v1.ScopeArticlesStub) + return slices.ContainsFunc(p.Scopes, func(s v1.Scope) bool { + return s == v1.ScopeArticlesWrite || s == v1.ScopeArticlesStub || s == v1.ScopeArticlesContribute + }) } func (p PrincipalConfig) principal() v1.Principal { @@ -379,7 +381,7 @@ func samePort(listen, main string) bool { } var ( - oidcScopes = []v1.Scope{v1.ScopeArticlesRead, v1.ScopeArticlesWrite, v1.ScopeArticlesStub, v1.ScopeRetrieveRead} + oidcScopes = []v1.Scope{v1.ScopeArticlesRead, v1.ScopeArticlesWrite, v1.ScopeArticlesStub, v1.ScopeArticlesContribute, v1.ScopeRetrieveRead} keyScopes = []v1.Scope{v1.ScopeArticlesRead, v1.ScopeArticlesReadAll, v1.ScopeArticlesModerate, v1.ScopeArticlesAdmin, v1.ScopeCommunityIntake, v1.ScopeWorkBroker} ) @@ -501,7 +503,7 @@ func (rp rawPrincipal) validate() (PrincipalConfig, error) { p.WritesPerDay = *rp.WritesPerDay } } else if rp.WritesPerHour != nil || rp.WritesBurst != nil || rp.WritesPerDay != nil { - return p, errors.New("writes_*: only allowed with articles:write or articles:stub") + return p, errors.New("writes_*: only allowed with articles:write, articles:stub or articles:contribute") } if rp.CountsReads != nil && *rp.CountsReads { if !slices.Contains(p.Scopes, v1.ScopeArticlesRead) || p.Env != v1.EnvProd { @@ -526,6 +528,9 @@ func checkScopes(kind v1.Kind, env v1.Env, scopes []v1.Scope) error { if slices.Contains(scopes, v1.ScopeArticlesAdmin) && (len(scopes) != 1 || kind != v1.KindKey || env != v1.EnvProd) { return errors.New("scopes: articles:admin must be the only scope of a prod key principal") } + if slices.Contains(scopes, v1.ScopeArticlesContribute) && (slices.Contains(scopes, v1.ScopeArticlesWrite) || slices.Contains(scopes, v1.ScopeArticlesStub)) { + return errors.New("scopes: articles:contribute may not be combined with articles:write or articles:stub") + } if slices.ContainsFunc(scopes, communityScope) { if slices.ContainsFunc(scopes, func(s v1.Scope) bool { return !communityScope(s) }) { return errors.New("scopes: community:intake and work:broker may not be combined with other scopes") diff --git a/internal/svcauth/contribute_test.go b/internal/svcauth/contribute_test.go new file mode 100644 index 0000000..575bc2c --- /dev/null +++ b/internal/svcauth/contribute_test.go @@ -0,0 +1,153 @@ +package svcauth + +import ( + "context" + "net/http" + "slices" + "strings" + "testing" + "time" + + v1 "github.com/pilot-protocol/cosift/internal/v1" +) + +const ( + intakeSub = "100000000000000000011" + intakeEmail = "intake-prod@cosift-test.iam.gserviceaccount.com" + intakeStgSub = "100000000000000000012" + intakeStgEmail = "intake-staging@cosift-test.iam.gserviceaccount.com" +) + +func contributeConfig() map[string]any { + c := baseConfig() + c["principals"] = append(principalsOf(c), + map[string]any{"id": "intake-prod", "kind": "oidc", "sub": intakeSub, "email": intakeEmail, "env": "prod", "scopes": []string{"articles:read", "articles:contribute", "retrieve:read"}, "rpm": 300, "writes_per_hour": 120, "writes_burst": 20, "writes_per_day": 700}, + map[string]any{"id": "intake-staging", "kind": "oidc", "sub": intakeStgSub, "email": intakeStgEmail, "env": "staging", "scopes": []string{"articles:read", "articles:contribute"}, "rpm": 60, "writes_per_hour": 5, "writes_burst": 2, "writes_per_day": 5}, + ) + return c +} + +func TestParseContributeScope(t *testing.T) { + cfg, err := Parse(mustJSON(contributeConfig()), testChecks()) + if err != nil { + t.Fatal(err) + } + var p PrincipalConfig + for _, pc := range cfg.Principals { + if pc.ID == "intake-prod" { + p = pc + } + } + if p.ID == "" || !p.Writer() || !slices.Contains(p.Scopes, v1.Scope("articles:contribute")) || p.WritesPerHour != 120 || p.WritesBurst != 20 || p.WritesPerDay != 700 { + t.Fatalf("intake-prod %+v", p) + } + + c := contributeConfig() + delete(principalByID(c, "intake-prod"), "writes_per_hour") + delete(principalByID(c, "intake-prod"), "writes_burst") + delete(principalByID(c, "intake-prod"), "writes_per_day") + cfg, err = Parse(mustJSON(c), testChecks()) + if err != nil { + t.Fatal(err) + } + for _, pc := range cfg.Principals { + if pc.ID == "intake-prod" && (pc.WritesPerHour != 30 || pc.WritesBurst != 10 || pc.WritesPerDay != 150) { + t.Fatalf("default budget %+v", pc) + } + } +} + +func TestParseContributeScopeRefused(t *testing.T) { + cases := []struct { + name string + edit func(map[string]any) + want string + }{ + {"beside articles:write", func(c map[string]any) { + principalByID(c, "intake-prod")["scopes"] = []string{"articles:read", "articles:contribute", "articles:write"} + }, "articles:contribute may not be combined with articles:write or articles:stub"}, + {"beside articles:stub", func(c map[string]any) { + principalByID(c, "intake-prod")["scopes"] = []string{"articles:stub", "articles:contribute"} + }, "articles:contribute may not be combined with articles:write or articles:stub"}, + {"on a key principal", func(c map[string]any) { + principalByID(c, "community-wiki")["scopes"] = []string{"articles:read", "articles:contribute"} + }, "articles:contribute not allowed on a key principal"}, + {"writes_* without a writer scope", func(c map[string]any) { + principalByID(c, "mcp-prod")["writes_per_day"] = 3 + }, "writes_*: only allowed with articles:write, articles:stub or articles:contribute"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := contributeConfig() + tc.edit(c) + _, err := Parse(mustJSON(c), testChecks()) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error %v, want %q", err, tc.want) + } + }) + } +} + +func TestContributeIsBudgetedAndCountedAsStagingWriter(t *testing.T) { + cfg := contributeConfig() + cfg["golive_at"] = "2026-10-12T16:00:00Z" + h := newHarness(t, cfg) + pol := h.svc.Policy + if got := strings.Join(pol.StagingWriters(), ","); got != "intake-staging,resolver-staging,synth-staging" { + t.Fatalf("staging writers %q", got) + } + h.clock.t = time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + for i := range 2 { + if _, ok := pol.ChargeWrite("intake-staging"); !ok { + t.Fatalf("charge %d refused", i) + } + } + if _, ok := pol.ChargeWrite("intake-staging"); ok { + t.Fatal("burst of 2 not enforced for a contribute principal") + } + if pol.WritesExhausted("intake-prod") { + t.Fatal("fresh daily allowance reported spent") + } + if _, ok := pol.ChargeWrite("intake-prod"); !ok { + t.Fatal("intake-prod cannot charge a write") + } +} + +func TestIntakeProdBudgetExhaustsAtTheHourAndDayWhileReadsPass(t *testing.T) { + h, _ := newFakeArticlesHarness(t, contributeConfig()) + pol := h.svc.Policy + h.clock.t = time.Now().UTC().Truncate(24 * time.Hour).Add(48 * time.Hour) + for i := range 20 { + if _, ok := pol.ChargeWrite("intake-prod"); !ok { + t.Fatalf("burst charge %d refused", i) + } + } + retry, ok := pol.ChargeWrite("intake-prod") + if ok || retry != 30*time.Second { + t.Fatalf("hourly burst of 20: %v %s", ok, retry) + } + if pol.WritesExhausted("intake-prod") { + t.Fatal("an hourly limit is not an exhausted day") + } + used := 20 + for ; used < 700; used++ { + h.clock.Advance(30 * time.Second) + if _, ok := pol.ChargeWrite("intake-prod"); !ok { + t.Fatalf("charge %d refused before the daily 700", used) + } + } + h.clock.Advance(30 * time.Second) + if _, ok := pol.ChargeWrite("intake-prod"); ok { + t.Fatal("701st write of the day accepted") + } + if !pol.WritesExhausted("intake-prod") { + t.Fatal("daily allowance of 700 spent but not exhausted") + } + if !h.svc.certs.refresh(context.Background()) { + t.Fatal("refresh failed") + } + intake := h.token(intakeSub, intakeEmail) + if w := h.do("GET", "/v1/articles", "", bearerAuth(intake)); w.Code != http.StatusOK { + t.Fatalf("read with the write budget spent: %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/v1/policy.go b/internal/v1/policy.go index 8610455..9f10337 100644 --- a/internal/v1/policy.go +++ b/internal/v1/policy.go @@ -18,7 +18,7 @@ type Policy interface { // WritesExhausted reports, without charging, whether today's allowance is spent. WritesExhausted(id string) bool // StagingWriters returns the sorted ids of staging principals holding - // articles:write or articles:stub; empty, not nil, when there are none. + // articles:write, articles:stub or articles:contribute; empty, not nil, when there are none. StagingWriters() []string GoliveAt() (time.Time, bool) } @@ -140,7 +140,7 @@ func (f *FakePolicy) StagingWriters() []string { defer f.mu.Unlock() ids := []string{} for id, p := range f.principals { - if p.Env == EnvStaging && p.HasAny(ScopeArticlesWrite, ScopeArticlesStub) { + if p.Env == EnvStaging && p.HasAny(ScopeArticlesWrite, ScopeArticlesStub, ScopeArticlesContribute) { ids = append(ids, id) } } diff --git a/internal/v1/policy_test.go b/internal/v1/policy_test.go index 18c4b58..977a8fa 100644 --- a/internal/v1/policy_test.go +++ b/internal/v1/policy_test.go @@ -127,8 +127,9 @@ func TestFakePolicyStagingWriters(t *testing.T) { } zeta := Principal{ID: "zeta-staging", Env: EnvStaging, Scopes: []Scope{ScopeArticlesStub}} alpha := Principal{ID: "alpha-staging", Env: EnvStaging, Scopes: []Scope{ScopeArticlesWrite}} - f.SetPrincipals(zeta, synthProd, synthStaging, mcpStaging, resolverStaging, dashStaging, alpha, resolverProd) - want := []string{"alpha-staging", "resolver-staging", "synth-staging", "zeta-staging"} + mid := Principal{ID: "mid-staging", Env: EnvStaging, Scopes: []Scope{ScopeArticlesContribute}} + f.SetPrincipals(zeta, synthProd, synthStaging, mcpStaging, resolverStaging, dashStaging, alpha, resolverProd, mid) + want := []string{"alpha-staging", "mid-staging", "resolver-staging", "synth-staging", "zeta-staging"} if got := f.StagingWriters(); !slices.Equal(got, want) { t.Errorf("StagingWriters = %v, want %v", got, want) } diff --git a/internal/v1/principal.go b/internal/v1/principal.go index 206633c..1952209 100644 --- a/internal/v1/principal.go +++ b/internal/v1/principal.go @@ -28,20 +28,21 @@ func (e Env) Valid() bool { return e == EnvProd || e == EnvStaging } type Scope string const ( - ScopeArticlesRead Scope = "articles:read" - ScopeArticlesReadAll Scope = "articles:read_all" - ScopeArticlesWrite Scope = "articles:write" - ScopeArticlesStub Scope = "articles:stub" - ScopeArticlesModerate Scope = "articles:moderate" - ScopeArticlesAdmin Scope = "articles:admin" - ScopeRetrieveRead Scope = "retrieve:read" - ScopeCommunityIntake Scope = "community:intake" - ScopeWorkBroker Scope = "work:broker" + ScopeArticlesRead Scope = "articles:read" + ScopeArticlesReadAll Scope = "articles:read_all" + ScopeArticlesWrite Scope = "articles:write" + ScopeArticlesStub Scope = "articles:stub" + ScopeArticlesContribute Scope = "articles:contribute" + ScopeArticlesModerate Scope = "articles:moderate" + ScopeArticlesAdmin Scope = "articles:admin" + ScopeRetrieveRead Scope = "retrieve:read" + ScopeCommunityIntake Scope = "community:intake" + ScopeWorkBroker Scope = "work:broker" ) func (s Scope) Valid() bool { switch s { - case ScopeArticlesRead, ScopeArticlesReadAll, ScopeArticlesWrite, ScopeArticlesStub, + case ScopeArticlesRead, ScopeArticlesReadAll, ScopeArticlesWrite, ScopeArticlesStub, ScopeArticlesContribute, ScopeArticlesModerate, ScopeArticlesAdmin, ScopeRetrieveRead, ScopeCommunityIntake, ScopeWorkBroker: return true } diff --git a/internal/v1/principal_test.go b/internal/v1/principal_test.go index 4e20f6b..a3322ae 100644 --- a/internal/v1/principal_test.go +++ b/internal/v1/principal_test.go @@ -11,15 +11,16 @@ import ( func TestNames(t *testing.T) { scopes := map[Scope]string{ - ScopeArticlesRead: "articles:read", - ScopeArticlesReadAll: "articles:read_all", - ScopeArticlesWrite: "articles:write", - ScopeArticlesStub: "articles:stub", - ScopeArticlesModerate: "articles:moderate", - ScopeArticlesAdmin: "articles:admin", - ScopeRetrieveRead: "retrieve:read", - ScopeCommunityIntake: "community:intake", - ScopeWorkBroker: "work:broker", + ScopeArticlesRead: "articles:read", + ScopeArticlesReadAll: "articles:read_all", + ScopeArticlesWrite: "articles:write", + ScopeArticlesStub: "articles:stub", + ScopeArticlesContribute: "articles:contribute", + ScopeArticlesModerate: "articles:moderate", + ScopeArticlesAdmin: "articles:admin", + ScopeRetrieveRead: "retrieve:read", + ScopeCommunityIntake: "community:intake", + ScopeWorkBroker: "work:broker", } for s, name := range scopes { if string(s) != name { From 4042c6ff1411bf4fb948b3521c05a4808505d31d Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 01:41:30 +0300 Subject: [PATCH 002/121] feat(articles): origin, contributors and version_contributor on the record and its projections --- internal/articles/attribution_test.go | 114 ++++++++++++++++++++++++++ internal/articles/put.go | 4 + internal/articles/record.go | 63 ++++++++++---- internal/articles/validate.go | 5 ++ 4 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 internal/articles/attribution_test.go diff --git a/internal/articles/attribution_test.go b/internal/articles/attribution_test.go new file mode 100644 index 0000000..a2ed1a8 --- /dev/null +++ b/internal/articles/attribution_test.go @@ -0,0 +1,114 @@ +package articles + +import ( + "encoding/json" + "net/http" + "slices" + "sort" + "testing" + + "github.com/cockroachdb/pebble" +) + +func keysOf(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func hasKey(m map[string]any, k string) bool { _, ok := m[k]; return ok } + +func TestPublicProjectionCarriesAttribution(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.emb.set("Rust async runtimes", axis(1)) + h.emb.set("q covered", mix(1, 0, 0.9)) + h.put(synthProd, id, articleBody("Rust async runtimes"), http.StatusCreated) + + want := []string{"ai_generated", "body_md", "citations", "contributors", "created_at", "id", "lead", "origin", "promoted", "quality_tier", "slug", "status", "title", "updated_at", "version", "vertical"} + byID := h.call(wiki, http.MethodGet, "/v1/articles/"+id, nil, http.StatusOK) + bySlug := h.call(wiki, http.MethodGet, "/v1/articles/by-slug/rust-async-runtimes", nil, http.StatusOK) + covered := h.match(mcpProd, map[string]any{"q": "q covered"}, http.StatusOK)["article"].(map[string]any) + for name, got := range map[string]map[string]any{"get": byID, "by-slug": bySlug, "match": covered} { + if !slices.Equal(keysOf(got), want) { + t.Errorf("%s keys = %v, want %v", name, keysOf(got), want) + } + if got["origin"] != "synth" || len(got["contributors"].([]any)) != 0 { + t.Errorf("%s origin/contributors = %v %v", name, got["origin"], got["contributors"]) + } + } +} + +func TestFullProjectionCarriesAttribution(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, articleBody("Rust async runtimes"), http.StatusCreated) + full := h.call(dashProd, http.MethodGet, "/v1/articles/"+id, nil, http.StatusOK) + if full["origin"] != "synth" || len(full["contributors"].([]any)) != 0 { + t.Errorf("origin/contributors = %v %v", full["origin"], full["contributors"]) + } + if v, ok := full["version_contributor"]; !ok || v != nil { + t.Errorf("version_contributor = %v, present %v; want null", v, ok) + } + if hasKey(full, "reverts_to") { + t.Error("reverts_to present on a version no revert created") + } +} + +func TestSynthWriteIgnoresEngineOwnedAttribution(t *testing.T) { + h := newHarness(t) + id := ulid(1) + body := with(articleBody("Rust async runtimes"), "origin", "community", "contributors", []string{"0f1e2d3c4b5a6978"}, "version_contributor", "0f1e2d3c4b5a6978", "reverts_to", 3) + h.put(synthProd, id, body, http.StatusCreated) + full := h.call(dashProd, http.MethodGet, "/v1/articles/"+id, nil, http.StatusOK) + if full["origin"] != "synth" || len(full["contributors"].([]any)) != 0 || full["version_contributor"] != nil || hasKey(full, "reverts_to") { + t.Errorf("body fields were not ignored: %v", full) + } +} + +func TestStubAndResidueCarryNoAttribution(t *testing.T) { + h := newHarness(t) + h.call(resolverProd, http.MethodPut, "/v1/articles/"+ulid(1), stubBodyFor("Go memory model", topic(1)), http.StatusCreated) + stub := h.call(wiki, http.MethodGet, "/v1/articles/by-slug/go-memory-model", nil, http.StatusOK) + fullStub := h.call(dashProd, http.MethodGet, "/v1/articles/"+ulid(1), nil, http.StatusOK) + h.put(synthProd, ulid(2), articleBody("Rust async runtimes"), http.StatusCreated) + h.moderate(dashProd, ulid(2), "tombstone", "privacy", http.StatusOK) + residue := h.call(dashProd, http.MethodGet, "/v1/articles/"+ulid(2), nil, http.StatusOK) + for name, got := range map[string]map[string]any{"stub": stub, "full stub": fullStub, "residue": residue} { + for _, k := range []string{"origin", "contributors", "version_contributor", "reverts_to"} { + if hasKey(got, k) { + t.Errorf("%s carries %s", name, k) + } + } + } +} + +func TestPreContributionRecordReadsAsSynth(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, articleBody("Rust async runtimes"), http.StatusCreated) + raw, _ := h.raw(recordKey(id)) + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, k := range []string{"origin", "contributors", "version_contributor", "reverts_to"} { + delete(m, k) + } + legacy, _ := json.Marshal(m) + if err := h.ps.DB().Set(recordKey(id), legacy, pebble.Sync); err != nil { + t.Fatal(err) + } + h.restart() + got := h.call(wiki, http.MethodGet, "/v1/articles/"+id, nil, http.StatusOK) + if got["origin"] != "synth" || len(got["contributors"].([]any)) != 0 { + t.Errorf("legacy record = %v %v", got["origin"], got["contributors"]) + } + full := h.call(dashProd, http.MethodGet, "/v1/articles/"+id, nil, http.StatusOK) + if full["origin"] != "synth" { + t.Errorf("legacy full origin = %v", full["origin"]) + } +} diff --git a/internal/articles/put.go b/internal/articles/put.go index b561934..7d55f40 100644 --- a/internal/articles/put.go +++ b/internal/articles/put.go @@ -431,6 +431,10 @@ func (s *Store) planArticle(ix *index, p v1.Principal, id string, b *putBody, ch return nil, fail(errBlocked(p, blocker)) } } + if rec.Origin == "" { + rec.Origin = originSynth + } + rec.VersionContributor = nil pl.claims = newTopics pl.write, pl.content = true, true return pl, nil diff --git a/internal/articles/record.go b/internal/articles/record.go index eca94e9..06914f9 100644 --- a/internal/articles/record.go +++ b/internal/articles/record.go @@ -80,6 +80,11 @@ type Record struct { Moderation *Moderation `json:"moderation"` Prelive bool `json:"prelive"` + Origin string `json:"origin"` + Contributors []string `json:"contributors"` + VersionContributor *string `json:"version_contributor"` + RevertsTo int `json:"reverts_to"` + Residue bool `json:"residue"` ReasonCode string `json:"reason_code"` TombstonedAt string `json:"tombstoned_at"` @@ -143,6 +148,11 @@ type articleJSON struct { StatusChangedAt string `json:"status_changed_at"` Moderation *Moderation `json:"moderation"` Prelive bool `json:"prelive"` + + Origin string `json:"origin"` + Contributors []string `json:"contributors"` + VersionContributor *string `json:"version_contributor"` + RevertsTo int `json:"reverts_to,omitempty"` } func nonNil[T any](s []T) []T { @@ -168,7 +178,8 @@ func (r *Record) MarshalJSON() ([]byte, error) { return json.Marshal(articleJSON{r.Schema, r.ID, r.Slug, r.BaseSlug, r.Title, r.Lead, r.BodyMD, nonNil(r.Citations), r.QualityTier, r.Promoted, r.Vertical, r.Status, r.Sensitivity, r.AIGenerated, nonNil(r.Aliases), nonNil(r.TopicIDs), nonNil(r.ClusterIDs), r.Models, b, r.Version, r.ContentSHA256, - r.CreatedAt, r.UpdatedAt, r.StatusChangedAt, r.Moderation, r.Prelive}) + r.CreatedAt, r.UpdatedAt, r.StatusChangedAt, r.Moderation, r.Prelive, + r.origin(), nonNil(r.Contributors), r.VersionContributor, r.RevertsTo}) } func decodeRecord(b []byte) (*Record, error) { @@ -185,6 +196,11 @@ func (r *Record) clone() *Record { c.Aliases = slices.Clone(r.Aliases) c.TopicIDs = slices.Clone(r.TopicIDs) c.ClusterIDs = slices.Clone(r.ClusterIDs) + c.Contributors = slices.Clone(r.Contributors) + if r.VersionContributor != nil { + v := *r.VersionContributor + c.VersionContributor = &v + } if r.Moderation != nil { m := *r.Moderation c.Moderation = &m @@ -192,6 +208,19 @@ func (r *Record) clone() *Record { return &c } +const ( + originSynth = "synth" + originCommunity = "community" +) + +// origin reads a record written before contributions as synth. +func (r *Record) origin() string { + if r.Origin == originCommunity { + return originCommunity + } + return originSynth +} + func (r *Record) setStatus(status, at string) { r.Status = status r.StatusChangedAt = at @@ -236,25 +265,27 @@ func contentHash(r *Record) string { } type publicView struct { - ID string `json:"id"` - Slug string `json:"slug"` - Title string `json:"title"` - Lead string `json:"lead"` - BodyMD string `json:"body_md"` - Citations []Citation `json:"citations"` - QualityTier string `json:"quality_tier"` - Promoted bool `json:"promoted"` - Vertical string `json:"vertical"` - Status string `json:"status"` - AIGenerated bool `json:"ai_generated"` - Version int `json:"version"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Slug string `json:"slug"` + Title string `json:"title"` + Lead string `json:"lead"` + BodyMD string `json:"body_md"` + Citations []Citation `json:"citations"` + QualityTier string `json:"quality_tier"` + Promoted bool `json:"promoted"` + Vertical string `json:"vertical"` + Status string `json:"status"` + AIGenerated bool `json:"ai_generated"` + Origin string `json:"origin"` + Contributors []string `json:"contributors"` + Version int `json:"version"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } func (r *Record) public(promoted bool) publicView { return publicView{r.ID, r.Slug, r.Title, r.Lead, r.BodyMD, nonNil(r.Citations), r.QualityTier, promoted, - r.Vertical, r.Status, true, r.Version, r.CreatedAt, r.UpdatedAt} + r.Vertical, r.Status, true, r.origin(), nonNil(slices.Clone(r.Contributors)), r.Version, r.CreatedAt, r.UpdatedAt} } type stubView struct { diff --git a/internal/articles/validate.go b/internal/articles/validate.go index 02403e2..2ff09a8 100644 --- a/internal/articles/validate.go +++ b/internal/articles/validate.go @@ -55,6 +55,11 @@ type putBody struct { Residue json.RawMessage `json:"residue"` ReasonCode json.RawMessage `json:"reason_code"` TombstonedAt json.RawMessage `json:"tombstoned_at"` + + Origin json.RawMessage `json:"origin"` + Contributors json.RawMessage `json:"contributors"` + VersionContributor json.RawMessage `json:"version_contributor"` + RevertsTo json.RawMessage `json:"reverts_to"` } // stubBody is the stub PUT, decoded strictly. From fce6e792caf1112213fa20c6841a6cce2e586dad Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 01:44:55 +0300 Subject: [PATCH 003/121] feat(articles): the contribution PUT, its ten rules and the attribution it stamps --- internal/articles/contribute_test.go | 401 +++++++++++++++++++++++++++ internal/articles/errors.go | 8 + internal/articles/harness_test.go | 4 +- internal/articles/keys.go | 1 + internal/articles/match.go | 2 +- internal/articles/match_test.go | 2 +- internal/articles/put.go | 76 ++++- internal/articles/routes.go | 4 +- internal/articles/validate.go | 31 ++- 9 files changed, 502 insertions(+), 27 deletions(-) create mode 100644 internal/articles/contribute_test.go diff --git a/internal/articles/contribute_test.go b/internal/articles/contribute_test.go new file mode 100644 index 0000000..1cdc814 --- /dev/null +++ b/internal/articles/contribute_test.go @@ -0,0 +1,401 @@ +package articles + +import ( + "fmt" + "net/http" + "slices" + "testing" + "time" + + v1 "github.com/pilot-protocol/cosift/internal/v1" +) + +func cid(n int) string { return fmt.Sprintf("%016x", n+1) } + +func contribBody(title string, c string, kv ...any) map[string]any { + return with(articleBody(title), append([]any{"contributor", c}, kv...)...) +} + +func (h *harness) contribute(id string, body map[string]any, want int) map[string]any { + h.t.Helper() + return h.put(intakeProd, id, body, want) +} + +func (h *harness) contribError(id string, body map[string]any, status int, code string) map[string]any { + h.t.Helper() + return h.expectError(intakeProd, http.MethodPut, "/v1/articles/"+id, body, status, code) +} + +func wantInvalid(t *testing.T, out map[string]any, field, rule string) { + t.Helper() + if out["field"] != field || out["rule"] != rule { + t.Errorf("field/rule = %v/%v, want %s/%s", out["field"], out["rule"], field, rule) + } +} + +func fullOf(h *harness, id string) map[string]any { + h.t.Helper() + return h.call(dashProd, http.MethodGet, "/v1/articles/"+id, nil, http.StatusOK) +} + +func TestContributionShapeRules(t *testing.T) { + const title = "Rust async runtimes" + cases := []struct { + name string + p func() map[string]any + wantStatus int + field, rule string + asWriter bool + wantCode string + }{ + {"1 contributor absent", func() map[string]any { return articleBody(title) }, 422, "contributor", "required", false, "invalid_field"}, + {"1 contributor null", func() map[string]any { return with(articleBody(title), "contributor", json_null{}) }, 422, "contributor", "required", false, "invalid_field"}, + {"2 contributor pattern", func() map[string]any { return contribBody(title, "0F1E2D3C4B5A6978") }, 422, "contributor", "pattern", false, "invalid_field"}, + {"2 contributor too short", func() map[string]any { return contribBody(title, "0f1e2d3c") }, 422, "contributor", "pattern", false, "invalid_field"}, + {"2 contributor not a string", func() map[string]any { return with(articleBody(title), "contributor", 7) }, 422, "contributor", "pattern", false, "invalid_field"}, + {"3 write principal sends contributor", func() map[string]any { return contribBody(title, cid(1)) }, 422, "contributor", "field_not_allowed", true, "invalid_field"}, + {"4 held", func() map[string]any { return contribBody(title, cid(1), "status", "held") }, 422, "status", "must_be_published", false, "invalid_field"}, + {"4 status absent", func() map[string]any { return contribBody(title, cid(1), "status", nil) }, 422, "status", "must_be_published", false, "invalid_field"}, + {"5 sensitive", func() map[string]any { + return contribBody(title, cid(1), "sensitivity", map[string]any{"class": "medical_advice", "reason": "x"}) + }, 422, "sensitivity.class", "must_be_none", false, "invalid_field"}, + {"6 topic_ids", func() map[string]any { return contribBody(title, cid(1), "topic_ids", []string{topic(1)}) }, 422, "topic_ids", "field_not_allowed", false, "invalid_field"}, + {"6 cluster_ids", func() map[string]any { return contribBody(title, cid(1), "cluster_ids", []string{"c013960"}) }, 422, "cluster_ids", "field_not_allowed", false, "invalid_field"}, + } + for _, tc := range cases { + for _, existing := range []bool{false, true} { + name := tc.name + " on an absent id" + if existing { + name = tc.name + " on a record" + } + t.Run(name, func(t *testing.T) { + h := newHarness(t) + id := ulid(1) + if existing { + h.put(synthProd, id, articleBody(title), http.StatusCreated) + } + p := intakeProd + if tc.asWriter { + p = synthProd + } + body := tc.p() + if existing { + body["expected_version"] = 1 + } + out := h.expectError(p, http.MethodPut, "/v1/articles/"+id, body, tc.wantStatus, tc.wantCode) + wantInvalid(t, out, tc.field, tc.rule) + if !existing { + if _, ok := h.raw(recordKey(id)); ok { + t.Error("refused contribution created a record") + } + } + }) + } + } +} + +type json_null struct{} + +func (json_null) MarshalJSON() ([]byte, error) { return []byte("null"), nil } + +func TestContributionRule7ExpectedVersion(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(1)), http.StatusCreated) + changed := contribBody("Rust async runtimes", cid(2), "lead", "A revised lead. It differs.") + wantInvalid(t, h.contribError(id, changed, 422, "invalid_field"), "expected_version", "required") + if v := fullOf(h, id)["version"]; v != 1.0 { + t.Errorf("refused contribution wrote version %v", v) + } + h.contribute(id, with(changed, "expected_version", 1), http.StatusOK) + + h.call(resolverProd, http.MethodPut, "/v1/articles/"+ulid(2), stubBodyFor("Go memory model", topic(2)), http.StatusCreated) + wantInvalid(t, h.contribError(ulid(2), contribBody("Go memory model", cid(1)), 422, "invalid_field"), "expected_version", "required") + if out := h.contribute(ulid(2), with(contribBody("Go memory model", cid(1)), "expected_version", 0), http.StatusOK); out["result"] != "filled" { + t.Errorf("stub fill = %v", out) + } +} + +func TestContributionCheckOrder(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(1)), http.StatusCreated) + + wantInvalid(t, h.contribError(id, contribBody("Rust async runtimes", cid(2), "status", "held", "lead", "Another lead. It differs."), 422, "invalid_field"), "status", "must_be_published") + + same := contribBody("Rust async runtimes", cid(2)) + if out := h.contribute(id, same, http.StatusOK); out["result"] != "unchanged" { + t.Errorf("same-hash body without expected_version = %v", out) + } + if got := fullOf(h, id); !slices.Equal(toStrings(got["contributors"]), []string{cid(1)}) || got["version_contributor"] != cid(1) { + t.Errorf("unchanged contribution changed attribution: %v %v", got["contributors"], got["version_contributor"]) + } + + h.moderate(dashProd, id, "hold", "quality", http.StatusOK) + broken := contribBody("Rust async runtimes", cid(2), "status", "held", "sensitivity", map[string]any{"class": "allegation", "reason": ""}, "topic_ids", []string{topic(1)}, "lead", "Another lead. It differs.") + if out := h.contribError(id, broken, 409, "moderation_locked"); out["current_status"] != "held" { + t.Errorf("held record = %v", out) + } + if out := h.contribError(id, same, 409, "moderation_locked"); out["current_status"] != "held" { + t.Errorf("held record, same hash = %v", out) + } + h.moderate(dashProd, id, "approve", "", http.StatusOK) + + stale := contribBody("Rust async runtimes", cid(2), "quality_tier", "thin", "lead", "Another lead. It differs.", "expected_version", 5) + if out := h.contribError(id, stale, 409, "version_conflict"); out["current_version"] != 1.0 { + t.Errorf("stale tier-regressing body = %v", out) + } + h.contribError(id, with(stale, "expected_version", 1), 409, "tier_regression") +} + +func TestContributionStampsAttribution(t *testing.T) { + h := newHarness(t) + a, b := cid(1), cid(2) + + created := ulid(1) + h.contribute(created, contribBody("Rust async runtimes", a), http.StatusCreated) + got := fullOf(h, created) + if got["origin"] != "community" || !slices.Equal(toStrings(got["contributors"]), []string{a}) || got["version_contributor"] != a { + t.Errorf("create: %v %v %v", got["origin"], got["contributors"], got["version_contributor"]) + } + pub := h.call(wiki, http.MethodGet, "/v1/articles/"+created, nil, http.StatusOK) + if pub["origin"] != "community" || !slices.Equal(toStrings(pub["contributors"]), []string{a}) || hasKey(pub, "version_contributor") { + t.Errorf("public: %v", pub) + } + + h.contribute(created, contribBody("Rust async runtimes", a, "lead", "Second lead. By the same cid.", "expected_version", 1), http.StatusOK) + h.contribute(created, contribBody("Rust async runtimes", b, "lead", "Third lead. By another cid.", "expected_version", 2), http.StatusOK) + h.contribute(created, contribBody("Rust async runtimes", a, "lead", "Fourth lead. By the first again.", "expected_version", 3), http.StatusOK) + got = fullOf(h, created) + if !slices.Equal(toStrings(got["contributors"]), []string{a, b}) || got["version_contributor"] != a || got["version"] != 4.0 { + t.Errorf("update: %v %v v%v", got["contributors"], got["version_contributor"], got["version"]) + } + for v, want := range map[int]string{1: a, 2: a, 3: b} { + pv := h.call(dashProd, http.MethodGet, fmt.Sprintf("/v1/article-versions/%s/%d", created, v), nil, http.StatusOK) + if pv["version_contributor"] != want { + t.Errorf("version %d contributor = %v, want %s", v, pv["version_contributor"], want) + } + } + + filled := ulid(2) + h.call(resolverProd, http.MethodPut, "/v1/articles/"+filled, stubBodyFor("Go memory model", topic(2)), http.StatusCreated) + h.contribute(filled, with(contribBody("Go memory model", b), "expected_version", 0), http.StatusOK) + got = fullOf(h, filled) + if got["origin"] != "community" || !slices.Equal(toStrings(got["contributors"]), []string{b}) || got["version_contributor"] != b { + t.Errorf("fill: %v %v %v", got["origin"], got["contributors"], got["version_contributor"]) + } +} + +func TestContributionOnSynthRecordTurnsItCommunity(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, articleBody("Rust async runtimes"), http.StatusCreated) + if got := fullOf(h, id); got["origin"] != "synth" { + t.Fatalf("origin = %v", got["origin"]) + } + h.contribute(id, contribBody("Rust async runtimes", cid(1), "lead", "A contributed lead. It differs.", "expected_version", 1), http.StatusOK) + if got := fullOf(h, id); got["origin"] != "community" { + t.Errorf("origin after a contribution = %v", got["origin"]) + } +} + +func TestSynthPutKeepsAttribution(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(1)), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "lead", "A contributed lead. It differs.", "expected_version", 1), http.StatusOK) + + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", "A synth lead. It rebuilds the article.", "expected_version", 2), http.StatusOK) + got := fullOf(h, id) + if got["origin"] != "community" || !slices.Equal(toStrings(got["contributors"]), []string{cid(1), cid(2)}) { + t.Errorf("synth PUT changed origin/contributors: %v %v", got["origin"], got["contributors"]) + } + if v, ok := got["version_contributor"]; !ok || v != nil { + t.Errorf("version_contributor = %v (present %v), want null", v, ok) + } + if pv := h.call(dashProd, http.MethodGet, "/v1/article-versions/"+id+"/2", nil, http.StatusOK); pv["version_contributor"] != cid(2) { + t.Errorf("prior version contributor = %v", pv["version_contributor"]) + } + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", "A later synth lead. It rebuilds again."), http.StatusOK) + if got := fullOf(h, id); got["origin"] != "community" { + t.Errorf("origin went back to %v", got["origin"]) + } +} + +func TestRetriedContributionIsUnchanged(t *testing.T) { + h := newHarness(t) + id := ulid(1) + body := contribBody("Rust async runtimes", cid(1)) + if out := h.contribute(id, body, http.StatusCreated); out["result"] != "created" { + t.Fatalf("create = %v", out) + } + if out := h.contribute(id, body, http.StatusOK); out["result"] != "unchanged" || article(out)["version"] != 1.0 { + t.Errorf("retried create = %v", out) + } + got := fullOf(h, id) + if !slices.Equal(toStrings(got["contributors"]), []string{cid(1)}) || got["version_contributor"] != cid(1) { + t.Errorf("attribution after retry: %v %v", got["contributors"], got["version_contributor"]) + } + + update := contribBody("Rust async runtimes", cid(2), "lead", "An update lead. It differs.", "expected_version", 1) + h.contribute(id, update, http.StatusOK) + if out := h.contribute(id, update, http.StatusOK); out["result"] != "unchanged" || article(out)["version"] != 2.0 { + t.Errorf("retried update = %v", out) + } + if got := fullOf(h, id); !slices.Equal(toStrings(got["contributors"]), []string{cid(1), cid(2)}) || got["version_contributor"] != cid(2) { + t.Errorf("attribution after retried update: %v %v", got["contributors"], got["version_contributor"]) + } +} + +func fillContributors(h *harness, id string, n int) { + h.t.Helper() + for i := 0; i < n; i++ { + body := contribBody("Rust async runtimes", cid(i), "lead", fmt.Sprintf("Lead number %d. It differs each time.", i)) + if i == 0 { + h.contribute(id, body, http.StatusCreated) + continue + } + h.contribute(id, with(body, "expected_version", i), http.StatusOK) + } +} + +func TestContributorCap(t *testing.T) { + h := newHarness(t) + id := ulid(1) + fillContributors(h, id, 63) + if got := fullOf(h, id); len(got["contributors"].([]any)) != 63 || got["version"] != 63.0 { + t.Fatalf("setup: %d contributors, v%v", len(got["contributors"].([]any)), got["version"]) + } + h.contribute(id, contribBody("Rust async runtimes", cid(63), "lead", "The sixty-fourth lead. It is the last place.", "expected_version", 63), http.StatusOK) + got := fullOf(h, id) + if cs := toStrings(got["contributors"]); len(cs) != 64 || cs[63] != cid(63) { + t.Fatalf("64th contributor not appended: %d", len(cs)) + } + + refused := contribBody("Rust async runtimes", cid(64), "lead", "The sixty-fifth lead. It has no place.", "expected_version", 64) + h.contribError(id, refused, 409, "contributors_full") + after := fullOf(h, id) + if after["version"] != 64.0 || len(after["contributors"].([]any)) != 64 || after["version_contributor"] != cid(63) || after["lead"] != got["lead"] { + t.Errorf("refused contribution wrote: v%v %d %v", after["version"], len(after["contributors"].([]any)), after["version_contributor"]) + } + if _, ok := h.raw(versionKey(id, 64)); ok { + t.Error("refused contribution wrote a version") + } + + h.contribute(id, contribBody("Rust async runtimes", cid(5), "lead", "A known cid writes again. The list is unchanged.", "expected_version", 64), http.StatusOK) + after = fullOf(h, id) + if len(after["contributors"].([]any)) != 64 || after["version"] != 65.0 || after["version_contributor"] != cid(5) { + t.Errorf("known cid on a full list: %d v%v %v", len(after["contributors"].([]any)), after["version"], after["version_contributor"]) + } + + same := contribBody("Rust async runtimes", cid(70), "lead", "A known cid writes again. The list is unchanged.") + if out := h.contribute(id, same, http.StatusOK); out["result"] != "unchanged" { + t.Errorf("same-hash body from a new cid on a full record = %v", out) + } + if len(fullOf(h, id)["contributors"].([]any)) != 64 { + t.Error("unchanged contribution took a place") + } +} + +func TestContributorCapRace(t *testing.T) { + h := newHarness(t) + id := ulid(1) + fillContributors(h, id, 63) + first := contribBody("Rust async runtimes", cid(100), "lead", "First racer lead. It takes the place.", "expected_version", 63) + second := contribBody("Rust async runtimes", cid(101), "lead", "Second racer lead. It finds none.", "expected_version", 63) + h.contribute(id, first, http.StatusOK) + h.contribError(id, second, 409, "version_conflict") + h.contribError(id, with(second, "expected_version", 64), 409, "contributors_full") + if cs := toStrings(fullOf(h, id)["contributors"]); len(cs) != 64 || slices.Contains(cs, cid(101)) { + t.Errorf("contributors = %d", len(cs)) + } +} + +func TestContributionTierRegression(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(1), "quality_tier", "ok"), http.StatusCreated) + h.contribError(id, contribBody("Rust async runtimes", cid(2), "quality_tier", "thin", "lead", "A thinner lead. It differs.", "expected_version", 1), 409, "tier_regression") + h.contribute(id, contribBody("Rust async runtimes", cid(2), "quality_tier", "ok", "lead", "An equal tier lead. It differs.", "expected_version", 1), http.StatusOK) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "quality_tier", "strong", "lead", "A higher tier lead. It differs.", "expected_version", 2), http.StatusOK) + if got := fullOf(h, id); got["quality_tier"] != "strong" || got["version"] != 3.0 { + t.Errorf("tier = %v v%v", got["quality_tier"], got["version"]) + } + h.put(synthProd, ulid(2), articleBody("Go memory model"), http.StatusCreated) + h.put(synthProd, ulid(2), with(articleBody("Go memory model"), "quality_tier", "thin", "lead", "A synth rebuild. It lowers the tier."), http.StatusOK) +} + +func TestContributionLockedRecords(t *testing.T) { + h := newHarness(t) + for i, action := range []string{"reject", "tombstone"} { + id := ulid(i + 1) + title := []string{"Rust async runtimes", "Go memory model"}[i] + h.contribute(id, contribBody(title, cid(1)), http.StatusCreated) + h.moderate(dashProd, id, action, "quality", http.StatusOK) + h.contribError(id, with(contribBody(title, cid(1), "lead", "A new lead. It differs."), "expected_version", 1), 409, "moderation_locked") + } +} + +func TestContributionBudgetAndWriteGates(t *testing.T) { + h := newHarness(t) + h.contribute(ulid(1), contribBody("Rust async runtimes", cid(1)), http.StatusCreated) + if n := h.pol.Charges("intake-prod"); n != 1 { + t.Errorf("charges = %d", n) + } + h.contribError(ulid(2), contribBody("Go memory model", "bad"), 422, "invalid_field") + if n := h.pol.Charges("intake-prod"); n != 2 { + t.Errorf("refused contribution charges = %d, want 2", n) + } + h.pol.SetWriteBudget("intake-prod", 0, 90e9) + h.contribError(ulid(3), contribBody("Kafka consumer groups", cid(1)), 429, "write_budget") + h.pol.SetWritesFrozen(true) + h.contribError(ulid(3), contribBody("Kafka consumer groups", cid(1)), 503, "writes_frozen") +} + +func TestContributionAfterGolive(t *testing.T) { + h := newHarness(t) + h.pol.SetPrincipals(append([]v1.Principal{intakeStaging}, allPrincipals...)...) + h.put(intakeStaging, ulid(1), contribBody("Rust async runtimes", cid(1)), http.StatusCreated) + closeStagingWrites(h) + purge(h, map[string]any{"apply": true, "expect_records": 1}, http.StatusOK) + h.pol.SetPrincipals(append([]v1.Principal{intakeStaging}, allPrincipals...)...) + h.expectError(intakeStaging, http.MethodPut, "/v1/articles/"+ulid(2), contribBody("Go memory model", cid(1)), http.StatusForbidden, "env_golive") +} + +func TestContributionEnvMismatch(t *testing.T) { + h := newHarness(t) + h.put(synthStaging, ulid(1), articleBody("Rust async runtimes"), http.StatusCreated) + h.contribError(ulid(1), with(contribBody("Rust async runtimes", cid(1), "lead", "A new lead. It differs."), "expected_version", 1), 403, "env_mismatch") +} + +func TestBuildMatchAcceptsContribute(t *testing.T) { + h := newHarness(t) + body := map[string]any{"q": "Rust async runtimes", "purpose": "build", "topic_ids": []string{topic(1)}} + if out := h.match(intakeProd, body, http.StatusOK); out["verdict"] != "none" || out["writes"] != "open" { + t.Errorf("build match = %v", out) + } + h.expectError(mcpProd, http.MethodPost, "/v1/articles/match", body, http.StatusForbidden, "missing_scope") +} + +func TestContributionRestorePending(t *testing.T) { + h := newHarness(t) + h.pol.SetGoliveAt(time.Date(2026, 10, 12, 16, 0, 0, 0, time.UTC)) + h.contribError(ulid(1), contribBody("Rust async runtimes", cid(1)), 503, "restore_pending") +} + +func TestContributionRestorePendingRecheckedInLock(t *testing.T) { + h := newHarness(t) + block := make(chan struct{}) + h.emb.setBlock(block) + done := make(chan map[string]any) + go func() { done <- inFlight(h, intakeProd, ulid(1), contribBody("Rust async runtimes", cid(1))) }() + waitFor(t, func() bool { return h.emb.embedded("Rust async runtimes") }) + h.pol.SetGoliveAt(time.Date(2026, 10, 12, 16, 0, 0, 0, time.UTC)) + close(block) + if out := <-done; out["code"] != "restore_pending" { + t.Errorf("contribution released after golive_at appeared = %v", out) + } + if _, ok := h.raw(recordKey(ulid(1))); ok { + t.Error("the contribution landed") + } +} diff --git a/internal/articles/errors.go b/internal/articles/errors.go index 6947366..d8b8b7b 100644 --- a/internal/articles/errors.go +++ b/internal/articles/errors.go @@ -37,6 +37,14 @@ func errBlocked(p v1.Principal, blocker *Record) v1.Error { return conflictWith(v1.Error{Status: http.StatusConflict, Code: "blocked", Detail: "title is blocked by a moderated record"}, p, blocker) } +func errTierRegression() v1.Error { + return v1.Error{Status: http.StatusConflict, Code: "tier_regression", Detail: "quality tier is below the stored article's"} +} + +func errContributorsFull() v1.Error { + return v1.Error{Status: http.StatusConflict, Code: "contributors_full", Detail: "article has the maximum number of contributors"} +} + func errVersionConflict(current *int) v1.Error { return v1.Error{Status: http.StatusConflict, Code: "version_conflict", Detail: "version conflict", CurrentVersion: current} } diff --git a/internal/articles/harness_test.go b/internal/articles/harness_test.go index 614e808..1a19d72 100644 --- a/internal/articles/harness_test.go +++ b/internal/articles/harness_test.go @@ -33,12 +33,14 @@ var ( resolverStaging = v1.Principal{ID: "resolver-staging", Kind: v1.KindOIDC, Env: v1.EnvStaging, Scopes: []v1.Scope{v1.ScopeArticlesRead, v1.ScopeArticlesStub, v1.ScopeRetrieveRead}} mcpProd = v1.Principal{ID: "mcp-prod", Kind: v1.KindOIDC, Env: v1.EnvProd, Scopes: []v1.Scope{v1.ScopeArticlesRead}, CountsReads: true} mcpStaging = v1.Principal{ID: "mcp-staging", Kind: v1.KindOIDC, Env: v1.EnvStaging, Scopes: []v1.Scope{v1.ScopeArticlesRead}} + intakeProd = v1.Principal{ID: "intake-prod", Kind: v1.KindOIDC, Env: v1.EnvProd, Scopes: []v1.Scope{v1.ScopeArticlesRead, v1.ScopeArticlesContribute, v1.ScopeRetrieveRead}} + intakeStaging = v1.Principal{ID: "intake-staging", Kind: v1.KindOIDC, Env: v1.EnvStaging, Scopes: []v1.Scope{v1.ScopeArticlesRead, v1.ScopeArticlesContribute}} wiki = v1.Principal{ID: "community-wiki", Kind: v1.KindKey, Env: v1.EnvProd, Scopes: []v1.Scope{v1.ScopeArticlesRead}} dashProd = v1.Principal{ID: "dash-prod", Kind: v1.KindKey, Env: v1.EnvProd, Scopes: []v1.Scope{v1.ScopeArticlesReadAll, v1.ScopeArticlesModerate}} dashStaging = v1.Principal{ID: "dash-staging", Kind: v1.KindKey, Env: v1.EnvStaging, Scopes: []v1.Scope{v1.ScopeArticlesReadAll, v1.ScopeArticlesModerate}} goliveAdmin = v1.Principal{ID: "golive-admin", Kind: v1.KindKey, Env: v1.EnvProd, Scopes: []v1.Scope{v1.ScopeArticlesAdmin}} - allPrincipals = []v1.Principal{synthProd, synthStaging, resolverProd, resolverStaging, mcpProd, mcpStaging, wiki, dashProd, dashStaging, goliveAdmin} + allPrincipals = []v1.Principal{synthProd, synthStaging, resolverProd, resolverStaging, intakeProd, mcpProd, mcpStaging, wiki, dashProd, dashStaging, goliveAdmin} ) // fakeEmbedder returns fixed vectors for known texts and a hash-seeded diff --git a/internal/articles/keys.go b/internal/articles/keys.go index 7810a1b..7103a6c 100644 --- a/internal/articles/keys.go +++ b/internal/articles/keys.go @@ -22,6 +22,7 @@ var ( idPattern = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{26}$`) slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) topicPattern = regexp.MustCompile(`^[0-9a-f]{16}$`) + cidPattern = regexp.MustCompile(`^[0-9a-f]{16}$`) clusterPattern = regexp.MustCompile(`^c[0-9]{6,}$`) readerPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) ) diff --git a/internal/articles/match.go b/internal/articles/match.go index 9ddb067..33ea3d4 100644 --- a/internal/articles/match.go +++ b/internal/articles/match.go @@ -77,7 +77,7 @@ func (s *Store) handleMatch(w http.ResponseWriter, r *http.Request, p v1.Princip if !allowed(w, r, v1.ScopeArticlesRead) { return } - if build && !allowed(w, r, v1.ScopeArticlesWrite, v1.ScopeArticlesStub) { + if build && !allowed(w, r, v1.ScopeArticlesWrite, v1.ScopeArticlesStub, v1.ScopeArticlesContribute) { return } if s.refuseRestorePending(w) { diff --git a/internal/articles/match_test.go b/internal/articles/match_test.go index fd6ce1a..5d32f8a 100644 --- a/internal/articles/match_test.go +++ b/internal/articles/match_test.go @@ -190,7 +190,7 @@ func TestReadersCountedOnlyForCountingPrincipal(t *testing.T) { func TestBuildNeedsWriteOrStub(t *testing.T) { h := newHarness(t) body := map[string]any{"q": "x", "purpose": "build"} - if out := h.expectError(mcpProd, http.MethodPost, "/v1/articles/match", body, http.StatusForbidden, "missing_scope"); out["detail"] != "requires scope articles:write or articles:stub" { + if out := h.expectError(mcpProd, http.MethodPost, "/v1/articles/match", body, http.StatusForbidden, "missing_scope"); out["detail"] != "requires scope articles:write or articles:stub or articles:contribute" { t.Errorf("detail = %v", out["detail"]) } h.expectError(dashProd, http.MethodPost, "/v1/articles/match", body, http.StatusForbidden, "missing_scope") diff --git a/internal/articles/put.go b/internal/articles/put.go index 7d55f40..5ec6cf1 100644 --- a/internal/articles/put.go +++ b/internal/articles/put.go @@ -10,7 +10,46 @@ import ( v1 "github.com/pilot-protocol/cosift/internal/v1" ) -const keepVersions = 5 +const ( + keepVersions = 5 + maxContributors = 64 +) + +// writeScope is the scope an article PUT runs under; no principal holds both. +func writeScope(p v1.Principal) v1.Scope { + if p.Has(v1.ScopeArticlesContribute) { + return v1.ScopeArticlesContribute + } + return v1.ScopeArticlesWrite +} + +// contributionCid applies the shape rules of a contribution (a write principal may not send a contributor) and returns its cid. +func contributionCid(p v1.Principal, b *putBody) (string, *v1.Error) { + if !p.Has(v1.ScopeArticlesContribute) { + if b.Contributor != nil { + return "", fail(v1.InvalidField("contributor", "field_not_allowed")) + } + return "", nil + } + if len(b.Contributor) == 0 || string(b.Contributor) == "null" { + return "", fail(v1.InvalidField("contributor", "required")) + } + var cid string + if json.Unmarshal(b.Contributor, &cid) != nil || !cidPattern.MatchString(cid) { + return "", fail(v1.InvalidField("contributor", "pattern")) + } + switch { + case b.Status == nil || *b.Status != StatusPublished: + return "", fail(v1.InvalidField("status", "must_be_published")) + case b.Sensitivity == nil || b.Sensitivity.Class != "none": + return "", fail(v1.InvalidField("sensitivity.class", "must_be_none")) + case len(b.TopicIDs) > 0: + return "", fail(v1.InvalidField("topic_ids", "field_not_allowed")) + case len(b.ClusterIDs) > 0: + return "", fail(v1.InvalidField("cluster_ids", "field_not_allowed")) + } + return cid, nil +} type dropped struct { Aliases int `json:"aliases"` @@ -151,7 +190,7 @@ func (s *Store) envFirst(w http.ResponseWriter, p v1.Principal, id string) bool } func (s *Store) putArticle(w http.ResponseWriter, r *http.Request, p v1.Principal, id string, b *putBody) { - if !allowed(w, r, v1.ScopeArticlesWrite) || s.refuseRestorePending(w) || !s.chargeWrite(w, p) || !s.envFirst(w, p, id) { + if !allowed(w, r, writeScope(p)) || s.refuseRestorePending(w) || !s.chargeWrite(w, p) || !s.envFirst(w, p, id) { return } checked := false @@ -215,7 +254,7 @@ func (s *Store) putArticle(w http.ResponseWriter, r *http.Request, p v1.Principa func (s *Store) commitArticle(p v1.Principal, id string, b *putBody, vecs map[string][]float32, check func() *v1.Error) (*plan, *v1.Error, bool) { s.writeMu.Lock() defer s.writeMu.Unlock() - a, e := s.activeLocked(p, v1.ScopeArticlesWrite, true) + a, e := s.activeLocked(p, writeScope(p), true) if e != nil { return nil, e, false } @@ -341,17 +380,22 @@ func (s *Store) planArticle(ix *index, p v1.Principal, id string, b *putBody, ch } now := s.nowString() prev := ix.recs[id] + contribute := p.Has(v1.ScopeArticlesContribute) if prev != nil { if !p.SameEnv(prev.Prelive) { return nil, fail(v1.EnvMismatch()) } - if prev.Status == StatusRejected || prev.Status == StatusTombstoned { + if prev.Status == StatusRejected || prev.Status == StatusTombstoned || (contribute && prev.Status == StatusHeld) { return nil, fail(errModerationLocked(prev.Status)) } if b.Title != prev.Title { return nil, fail(v1.InvalidField("title", "title_immutable")) } } + cid, e := contributionCid(p, b) + if e != nil { + return nil, e + } rec := &Record{Schema: 1, ID: id, Title: b.Title, AIGenerated: true} if prev != nil { rec = prev.clone() @@ -381,6 +425,9 @@ func (s *Store) planArticle(ix *index, p v1.Principal, id string, b *putBody, ch } return pl, nil } + if contribute && prev != nil && b.ExpectedVersion == nil { + return nil, fail(v1.InvalidField("expected_version", "required")) + } if e := check(); e != nil { return nil, e } @@ -393,6 +440,14 @@ func (s *Store) planArticle(ix *index, p v1.Principal, id string, b *putBody, ch v := prev.Version return nil, fail(errVersionConflict(&v)) } + if contribute { + if tierRank[prev.QualityTier] > tierRank[b.QualityTier] { + return nil, fail(errTierRegression()) + } + if len(prev.Contributors) >= maxContributors && !slices.Contains(prev.Contributors, cid) { + return nil, fail(errContributorsFull()) + } + } status := requested if prev.Status == StatusHeld { status = StatusHeld @@ -431,10 +486,17 @@ func (s *Store) planArticle(ix *index, p v1.Principal, id string, b *putBody, ch return nil, fail(errBlocked(p, blocker)) } } - if rec.Origin == "" { - rec.Origin = originSynth + if contribute { + rec.Origin, rec.VersionContributor = originCommunity, &cid + if !slices.Contains(rec.Contributors, cid) { + rec.Contributors = append(rec.Contributors, cid) + } + } else { + if rec.Origin == "" { + rec.Origin = originSynth + } + rec.VersionContributor = nil } - rec.VersionContributor = nil pl.claims = newTopics pl.write, pl.content = true, true return pl, nil diff --git a/internal/articles/routes.go b/internal/articles/routes.go index dc58ae8..617008e 100644 --- a/internal/articles/routes.go +++ b/internal/articles/routes.go @@ -28,7 +28,7 @@ func (s *Store) Routes() []v1.Route { {Method: http.MethodGet, Path: "/v1/articles/{id}", Scopes: []v1.Scope{read, readAll}, Handler: s.gated(s.handleGet)}, {Method: http.MethodGet, Path: "/v1/articles/by-slug/{slug}", Scopes: []v1.Scope{read, readAll}, Handler: s.gated(s.handleBySlug)}, {Method: http.MethodGet, Path: "/v1/articles/stats", Scopes: []v1.Scope{readAll}, Handler: s.gated(s.handleStats)}, - {Method: http.MethodPut, Path: "/v1/articles/{id}", Scopes: []v1.Scope{v1.ScopeArticlesWrite, v1.ScopeArticlesStub}, BodyLimit: limitPut, Handler: s.gated(s.handlePut)}, + {Method: http.MethodPut, Path: "/v1/articles/{id}", Scopes: []v1.Scope{v1.ScopeArticlesWrite, v1.ScopeArticlesStub, v1.ScopeArticlesContribute}, BodyLimit: limitPut, Handler: s.gated(s.handlePut)}, {Method: http.MethodDelete, Path: "/v1/articles/{id}", Scopes: []v1.Scope{v1.ScopeArticlesStub}, Handler: s.gated(s.handleDeleteStub)}, {Method: http.MethodPost, Path: "/v1/articles/{id}/status", Scopes: []v1.Scope{v1.ScopeArticlesModerate}, BodyLimit: limitStatus, Handler: s.gated(s.handleStatus)}, {Method: http.MethodPost, Path: "/v1/articles/purge-prelive", Scopes: []v1.Scope{v1.ScopeArticlesAdmin}, BodyLimit: limitPurge, Handler: s.gated(s.handlePurge)}, @@ -105,7 +105,7 @@ func (s *Store) activeLocked(p v1.Principal, need v1.Scope, write bool) (v1.Prin if write && s.policy.WritesFrozen() { return a, fail(v1.WritesFrozen()) } - if need == v1.ScopeArticlesWrite || need == v1.ScopeArticlesStub { + if need == v1.ScopeArticlesWrite || need == v1.ScopeArticlesStub || need == v1.ScopeArticlesContribute { if _, set := s.policy.GoliveAt(); set && s.idx.golive == nil { return a, fail(errRestorePending()) } diff --git a/internal/articles/validate.go b/internal/articles/validate.go index 2ff09a8..c882f2f 100644 --- a/internal/articles/validate.go +++ b/internal/articles/validate.go @@ -24,21 +24,22 @@ var ( // putBody is the article PUT; engine-owned fields are accepted and ignored. type putBody struct { - Status *string `json:"status"` - ExpectedVersion *int `json:"expected_version"` - Title string `json:"title"` - Lead string `json:"lead"` - BodyMD string `json:"body_md"` - Citations []Citation `json:"citations"` - QualityTier string `json:"quality_tier"` - Vertical string `json:"vertical"` - Sensitivity *Sensitivity `json:"sensitivity"` - Aliases []string `json:"aliases"` - TopicIDs []string `json:"topic_ids"` - ClusterIDs []string `json:"cluster_ids"` - Models *Models `json:"models"` - Build *Build `json:"build"` - AIGenerated *bool `json:"ai_generated"` + Status *string `json:"status"` + ExpectedVersion *int `json:"expected_version"` + Title string `json:"title"` + Lead string `json:"lead"` + BodyMD string `json:"body_md"` + Citations []Citation `json:"citations"` + QualityTier string `json:"quality_tier"` + Vertical string `json:"vertical"` + Sensitivity *Sensitivity `json:"sensitivity"` + Aliases []string `json:"aliases"` + TopicIDs []string `json:"topic_ids"` + ClusterIDs []string `json:"cluster_ids"` + Models *Models `json:"models"` + Build *Build `json:"build"` + AIGenerated *bool `json:"ai_generated"` + Contributor json.RawMessage `json:"contributor"` Schema json.RawMessage `json:"schema"` ID json.RawMessage `json:"id"` From 0111a3f27b02c1e7b49f45871ed1fb528792ad04 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 01:52:13 +0300 Subject: [PATCH 004/121] feat(articles): twenty kept versions for a community record, attribution on version rows, reserved contributor slugs --- internal/articles/put.go | 21 +++-- internal/articles/read.go | 18 ++-- internal/articles/title.go | 2 +- internal/articles/versions_test.go | 138 +++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 internal/articles/versions_test.go diff --git a/internal/articles/put.go b/internal/articles/put.go index 5ec6cf1..5274286 100644 --- a/internal/articles/put.go +++ b/internal/articles/put.go @@ -11,10 +11,19 @@ import ( ) const ( - keepVersions = 5 - maxContributors = 64 + keepVersions = 5 + keepVersionsCommunity = 20 + maxContributors = 64 ) +// keepFor is how many prior versions a record keeps. +func keepFor(r *Record) int { + if r.origin() == originCommunity { + return keepVersionsCommunity + } + return keepVersions +} + // writeScope is the scope an article PUT runs under; no principal holds both. func writeScope(p v1.Principal) v1.Scope { if p.Has(v1.ScopeArticlesContribute) { @@ -307,7 +316,7 @@ func (s *Store) commitArticle(p v1.Principal, id string, b *putBody, vecs map[st if pl.result == "updated" && pl.content { prevJSON, _ := pl.prev.MarshalJSON() _ = bt.Set(versionKey(id, pl.prev.Version), prevJSON, nil) - if err := s.pruneVersions(bt, id, pl.prev.Version); err != nil { + if err := s.pruneVersions(bt, id, pl.prev.Version, keepFor(pl.rec)); err != nil { bt.Close() return nil, fail(v1.InternalError()), false } @@ -350,9 +359,9 @@ func storedVectors(r *Record, rows []row) map[string][]float32 { return out } -// pruneVersions deletes prior versions of id beyond the newest keepVersions, +// pruneVersions deletes prior versions of id beyond the newest keep, // counting newest as one of them. -func (s *Store) pruneVersions(bt *pebble.Batch, id string, newest int) error { +func (s *Store) pruneVersions(bt *pebble.Batch, id string, newest, keep int) error { lower, upper := versionPrefix(id) it, err := s.db.NewIter(&pebble.IterOptions{LowerBound: lower, UpperBound: upper}) if err != nil { @@ -366,7 +375,7 @@ func (s *Store) pruneVersions(bt *pebble.Batch, id string, newest int) error { } } slices.Sort(versions) - for len(versions) > keepVersions { + for len(versions) > keep { _ = bt.Delete(versionKey(id, versions[0]), nil) versions = versions[1:] } diff --git a/internal/articles/read.go b/internal/articles/read.go index a022fa9..64fac58 100644 --- a/internal/articles/read.go +++ b/internal/articles/read.go @@ -436,12 +436,16 @@ func (s *Store) handleStats(w http.ResponseWriter, r *http.Request, p v1.Princip } type versionSummary struct { - Version int `json:"version"` - UpdatedAt string `json:"updated_at"` - QualityTier string `json:"quality_tier"` - ContentSHA256 string `json:"content_sha256"` - Models Models `json:"models"` - Build Build `json:"build"` + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` + Status string `json:"status"` + SensitivityClass string `json:"sensitivity_class"` + QualityTier string `json:"quality_tier"` + ContentSHA256 string `json:"content_sha256"` + Models Models `json:"models"` + Build Build `json:"build"` + VersionContributor *string `json:"version_contributor"` + RevertsTo int `json:"reverts_to,omitempty"` } func (s *Store) handleVersions(w http.ResponseWriter, r *http.Request, p v1.Principal) { @@ -474,7 +478,7 @@ func (s *Store) handleVersions(w http.ResponseWriter, r *http.Request, p v1.Prin if len(v.Build.RetrievalParams) == 0 { v.Build.RetrievalParams = json.RawMessage("{}") } - out = append(out, versionSummary{v.Version, v.UpdatedAt, v.QualityTier, v.ContentSHA256, v.Models, v.Build}) + out = append(out, versionSummary{v.Version, v.UpdatedAt, v.Status, v.Sensitivity.Class, v.QualityTier, v.ContentSHA256, v.Models, v.Build, v.VersionContributor, v.RevertsTo}) } v1.WriteJSON(w, http.StatusOK, map[string]any{"current": rec.Version, "versions": out}) } diff --git a/internal/articles/title.go b/internal/articles/title.go index 7497c8c..a970dc7 100644 --- a/internal/articles/title.go +++ b/internal/articles/title.go @@ -263,7 +263,7 @@ func cutSlug(s string) string { return strings.Trim(s, "-") } -var reservedSlugs = set("v", "index", "sitemap", "search", "api", "admin", "random", "new") +var reservedSlugs = set("v", "index", "sitemap", "search", "api", "admin", "random", "new", "style", "terms", "wanted") // assignSlug returns base when it is free, else the first free disambiguated form. func assignSlug(base string, taken func(string) bool) string { diff --git a/internal/articles/versions_test.go b/internal/articles/versions_test.go new file mode 100644 index 0000000..09b3670 --- /dev/null +++ b/internal/articles/versions_test.go @@ -0,0 +1,138 @@ +package articles + +import ( + "fmt" + "net/http" + "slices" + "testing" +) + +func keptVersions(h *harness, id string) []int { + h.t.Helper() + out := h.call(dashProd, http.MethodGet, "/v1/article-versions/"+id, nil, http.StatusOK) + var vs []int + for _, v := range out["versions"].([]any) { + vs = append(vs, int(v.(map[string]any)["version"].(float64))) + } + return vs +} + +func topRange(lo, hi int) []int { + var out []int + for v := hi; v >= lo; v-- { + out = append(out, v) + } + return out +} + +func synthRevise(h *harness, id string, n, current int) int { + h.t.Helper() + for i := 0; i < n; i++ { + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", fmt.Sprintf("Synth lead %d. It differs each time.", current+i), "expected_version", current+i), http.StatusOK) + } + return current + n +} + +func contribRevise(h *harness, id string, n, current int) int { + h.t.Helper() + for i := 0; i < n; i++ { + h.contribute(id, contribBody("Rust async runtimes", cid(current+i), "lead", fmt.Sprintf("Contributed lead %d. It differs each time.", current+i), "expected_version", current+i), http.StatusOK) + } + return current + n +} + +func TestSynthRecordKeepsFiveVersions(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, articleBody("Rust async runtimes"), http.StatusCreated) + cur := synthRevise(h, id, 8, 1) + if got := keptVersions(h, id); !slices.Equal(got, topRange(cur-5, cur-1)) { + t.Errorf("kept %v with the record at %d", got, cur) + } +} + +func TestCommunityRecordKeepsTwentyVersions(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(0)), http.StatusCreated) + cur := contribRevise(h, id, 25, 1) + if got := keptVersions(h, id); !slices.Equal(got, topRange(cur-20, cur-1)) { + t.Errorf("kept %v with the record at %d", got, cur) + } + for v := cur - 20; v < cur; v++ { + h.call(dashProd, http.MethodGet, fmt.Sprintf("/v1/article-versions/%s/%d", id, v), nil, http.StatusOK) + } + h.expectError(dashProd, http.MethodGet, fmt.Sprintf("/v1/article-versions/%s/%d", id, cur-21), nil, http.StatusNotFound, "not_found") +} + +func TestRecordTurningCommunityKeepsTwentyFromThen(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, articleBody("Rust async runtimes"), http.StatusCreated) + cur := synthRevise(h, id, 8, 1) + if got := keptVersions(h, id); len(got) != 5 { + t.Fatalf("synth kept %v", got) + } + cur = contribRevise(h, id, 1, cur) + if got := keptVersions(h, id); !slices.Equal(got, topRange(cur-6, cur-1)) { + t.Errorf("after the first contribution kept %v at %d: pruned versions came back or the new cap is not applied", got, cur) + } + cur = contribRevise(h, id, 30, cur) + if got := keptVersions(h, id); !slices.Equal(got, topRange(cur-20, cur-1)) { + t.Errorf("kept %v with the record at %d", got, cur) + } +} + +func TestSynthRebuildOfCommunityRecordKeepsTwenty(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(0)), http.StatusCreated) + cur := contribRevise(h, id, 7, 1) + cur = synthRevise(h, id, 10, cur) + if got := keptVersions(h, id); !slices.Equal(got, topRange(1, cur-1)) { + t.Errorf("kept %v with the record at %d", got, cur) + } +} + +func TestVersionRowsCarryAttribution(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(1)), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "lead", "A second lead. It differs.", "expected_version", 1), http.StatusOK) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", "A synth lead. It differs.", "expected_version", 2), http.StatusOK) + rows := h.call(dashProd, http.MethodGet, "/v1/article-versions/"+id, nil, http.StatusOK)["versions"].([]any) + if len(rows) != 2 { + t.Fatalf("rows = %v", rows) + } + for i, want := range []string{cid(2), cid(1)} { + row := rows[i].(map[string]any) + if row["version_contributor"] != want || row["status"] != "published" || row["sensitivity_class"] != "none" || hasKey(row, "reverts_to") { + t.Errorf("row %d = %v", i, row) + } + } + if cur := h.call(dashProd, http.MethodGet, "/v1/article-versions/"+id, nil, http.StatusOK)["current"]; cur != 3.0 { + t.Errorf("current = %v", cur) + } +} + +func TestReservedContributorSlugs(t *testing.T) { + h := newHarness(t) + for i, title := range []string{"Style", "Terms", "Wanted", "Index"} { + out := h.put(synthProd, ulid(i+1), articleBody(title), http.StatusCreated) + if want := fmt.Sprintf("%s-2", []string{"style", "terms", "wanted", "index"}[i]); article(out)["slug"] != want { + t.Errorf("%s: slug %v, want %s", title, article(out)["slug"], want) + } + } +} + +func TestVersionRowsReportStatusAndSensitivity(t *testing.T) { + h := newHarness(t) + id := ulid(1) + sensitive := map[string]any{"class": "medical_advice", "reason": "dosage"} + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "status", "held", "sensitivity", sensitive), http.StatusCreated) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "status", "held", "lead", "A rebuilt lead. It differs.", "expected_version", 1), http.StatusOK) + rows := h.call(dashProd, http.MethodGet, "/v1/article-versions/"+id, nil, http.StatusOK)["versions"].([]any) + if row := rows[0].(map[string]any); row["status"] != "held" || row["sensitivity_class"] != "medical_advice" { + t.Errorf("row = %v", row) + } +} From 2adc0b7b7ba000f1f600bb50afafbe4265a60f22 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 01:55:05 +0300 Subject: [PATCH 005/121] feat(articles): status=pending and tier= list filters for articles:read, and the contributors on a tombstone answer --- internal/articles/listfilters_test.go | 134 ++++++++++++++++++++++++++ internal/articles/moderate.go | 7 +- internal/articles/read.go | 31 +++++- 3 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 internal/articles/listfilters_test.go diff --git a/internal/articles/listfilters_test.go b/internal/articles/listfilters_test.go new file mode 100644 index 0000000..542361a --- /dev/null +++ b/internal/articles/listfilters_test.go @@ -0,0 +1,134 @@ +package articles + +import ( + "net/http" + "net/url" + "slices" + "testing" +) + +func slugsOf(out map[string]any) []string { + var s []string + for _, it := range out["items"].([]any) { + s = append(s, it.(map[string]any)["slug"].(string)) + } + return s +} + +func wantedBoard(t *testing.T) *harness { + h := newHarness(t) + h.put(synthProd, ulid(1), articleBody("Alpha strong article"), http.StatusCreated) + h.put(synthProd, ulid(2), with(articleBody("Bravo ok article"), "quality_tier", "ok"), http.StatusCreated) + h.put(synthProd, ulid(3), with(articleBody("Charlie thin article"), "quality_tier", "thin"), http.StatusCreated) + h.put(synthProd, ulid(4), with(articleBody("Delta held article"), "quality_tier", "thin", "status", "held"), http.StatusCreated) + h.call(resolverProd, http.MethodPut, "/v1/articles/"+ulid(5), stubBodyFor("Echo wanted stub", topic(5)), http.StatusCreated) + h.call(resolverProd, http.MethodPut, "/v1/articles/"+ulid(6), stubBodyFor("Foxtrot wanted stub", topic(6)), http.StatusCreated) + h.call(resolverStaging, http.MethodPut, "/v1/articles/"+ulid(7), stubBodyFor("Golf staging stub", topic(7)), http.StatusCreated) + return h +} + +func TestListPendingForRead(t *testing.T) { + h := wantedBoard(t) + if got := slugsOf(h.call(wiki, http.MethodGet, "/v1/articles?status=pending", nil, http.StatusOK)); !slices.Equal(got, []string{"echo-wanted-stub", "foxtrot-wanted-stub"}) { + t.Errorf("prod pending = %v", got) + } + if got := slugsOf(h.call(mcpStaging, http.MethodGet, "/v1/articles?status=pending", nil, http.StatusOK)); !slices.Equal(got, []string{"echo-wanted-stub", "foxtrot-wanted-stub", "golf-staging-stub"}) { + t.Errorf("staging pending = %v", got) + } + if got := slugsOf(h.call(wiki, http.MethodGet, "/v1/articles", nil, http.StatusOK)); !slices.Equal(got, []string{"alpha-strong-article", "bravo-ok-article", "charlie-thin-article"}) { + t.Errorf("default list = %v", got) + } + if got := slugsOf(h.call(wiki, http.MethodGet, "/v1/articles?status=published", nil, http.StatusOK)); len(got) != 3 { + t.Errorf("published = %v", got) + } + if got := slugsOf(h.call(wiki, http.MethodGet, "/v1/articles?status=published,pending", nil, http.StatusOK)); len(got) != 5 { + t.Errorf("published,pending = %v", got) + } + out := h.call(wiki, http.MethodGet, "/v1/articles?status=pending&limit=1", nil, http.StatusOK) + stub := out["items"].([]any)[0].(map[string]any) + if stub["status"] != "pending" || stub["version"] != 0.0 || hasKey(stub, "quality_tier") || hasKey(stub, "origin") || hasKey(stub, "contributors") { + t.Errorf("stub item = %v", stub) + } + next, _ := out["next_cursor"].(string) + if next == "" { + t.Fatal("no cursor after the first stub") + } + if got := slugsOf(h.call(wiki, http.MethodGet, "/v1/articles?status=pending&cursor="+url.QueryEscape(next), nil, http.StatusOK)); !slices.Equal(got, []string{"foxtrot-wanted-stub"}) { + t.Errorf("second page = %v", got) + } + for _, q := range []string{"status=held", "status=pending,held", "status=rejected", "status=tombstoned", "status=bogus"} { + h.expectError(wiki, http.MethodGet, "/v1/articles?"+q, nil, http.StatusBadRequest, "invalid_param") + } + if got := slugsOf(h.call(dashProd, http.MethodGet, "/v1/articles?status=pending", nil, http.StatusOK)); len(got) != 3 { + t.Errorf("read_all pending = %v", got) + } +} + +func TestListTierFilter(t *testing.T) { + h := wantedBoard(t) + cases := map[string][]string{ + "tier=thin": {"charlie-thin-article"}, + "tier=strong": {"alpha-strong-article"}, + "tier=ok,thin": {"bravo-ok-article", "charlie-thin-article"}, + "status=published&tier=thin": {"charlie-thin-article"}, + "status=pending&tier=thin": nil, + "status=published,pending&tier=strong,ok": {"alpha-strong-article", "bravo-ok-article"}, + } + for q, want := range cases { + if got := slugsOf(h.call(wiki, http.MethodGet, "/v1/articles?"+q, nil, http.StatusOK)); !slices.Equal(got, want) { + t.Errorf("%s = %v, want %v", q, got, want) + } + } + for _, q := range []string{"tier=gold", "tier=", "tier=thin,", "tier=THIN"} { + h.expectError(wiki, http.MethodGet, "/v1/articles?"+q, nil, http.StatusBadRequest, "invalid_param") + } + if got := slugsOf(h.call(dashProd, http.MethodGet, "/v1/articles?tier=thin", nil, http.StatusOK)); !slices.Equal(got, []string{"charlie-thin-article", "delta-held-article"}) { + t.Errorf("read_all thin = %v", got) + } +} + +func TestListCursorBoundToStatusAndTier(t *testing.T) { + h := wantedBoard(t) + next := func(q string) string { + out := h.call(wiki, http.MethodGet, "/v1/articles?"+q+"&limit=1", nil, http.StatusOK) + c, _ := out["next_cursor"].(string) + if c == "" { + t.Fatalf("%s: no cursor", q) + } + return url.QueryEscape(c) + } + h.call(wiki, http.MethodGet, "/v1/articles?tier=strong,ok,thin&limit=1&cursor="+next("tier=strong,ok,thin"), nil, http.StatusOK) + h.expectError(wiki, http.MethodGet, "/v1/articles?tier=strong,ok&limit=1&cursor="+next("tier=strong,ok,thin"), nil, http.StatusBadRequest, "invalid_cursor") + h.expectError(wiki, http.MethodGet, "/v1/articles?limit=1&cursor="+next("tier=strong,ok,thin"), nil, http.StatusBadRequest, "invalid_cursor") + h.expectError(wiki, http.MethodGet, "/v1/articles?limit=1&cursor="+next("status=pending"), nil, http.StatusBadRequest, "invalid_cursor") + h.expectError(wiki, http.MethodGet, "/v1/articles?status=published&limit=1&cursor="+next("status=pending"), nil, http.StatusBadRequest, "invalid_cursor") +} + +func TestTombstoneAnswerListsContributors(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(1)), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "lead", "A second lead. It differs.", "expected_version", 1), http.StatusOK) + out := h.moderate(dashProd, id, "hold", "", http.StatusOK) + if hasKey(out, "contributors") { + t.Errorf("hold answered with contributors: %v", out) + } + h.moderate(dashProd, id, "approve", "", http.StatusOK) + out = h.moderate(dashProd, id, "tombstone", "privacy", http.StatusOK) + if got := toStrings(out["contributors"]); !slices.Equal(got, []string{cid(1), cid(2)}) { + t.Errorf("tombstone contributors = %v", out["contributors"]) + } + residue := fullOf(h, id) + if hasKey(residue, "contributors") || hasKey(residue, "origin") { + t.Errorf("residue kept attribution: %v", residue) + } + + h.put(synthProd, ulid(2), articleBody("Go memory model"), http.StatusCreated) + if out := h.moderate(dashProd, ulid(2), "tombstone", "privacy", http.StatusOK); !hasKey(out, "contributors") || len(out["contributors"].([]any)) != 0 { + t.Errorf("synth tombstone = %v", out) + } + h.put(synthProd, ulid(3), articleBody("Kafka consumer groups"), http.StatusCreated) + if out := h.moderate(dashProd, ulid(3), "reject", "quality", http.StatusOK); hasKey(out, "contributors") { + t.Errorf("reject answered with contributors: %v", out) + } +} diff --git a/internal/articles/moderate.go b/internal/articles/moderate.go index 1264a1d..1edff35 100644 --- a/internal/articles/moderate.go +++ b/internal/articles/moderate.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "slices" "time" "github.com/cockroachdb/pebble" @@ -138,7 +139,11 @@ func (s *Store) handleStatus(w http.ResponseWriter, r *http.Request, p v1.Princi if cur := s.idx.recs[id]; cur != nil { view.Status, view.StatusChangedAt, view.HasFingerprint = &cur.Status, cur.StatusChangedAt, s.idx.hasFingerprint(id) } - v1.WriteJSON(w, http.StatusOK, map[string]any{"result": "ok", "article": view}) + out := map[string]any{"result": "ok", "article": view} + if b.Action == "tombstone" { + out["contributors"] = nonNil(slices.Clone(rec.Contributors)) + } + v1.WriteJSON(w, http.StatusOK, out) } func (s *Store) putRecordLocked(r *Record) error { diff --git a/internal/articles/read.go b/internal/articles/read.go index 64fac58..3fadb3b 100644 --- a/internal/articles/read.go +++ b/internal/articles/read.go @@ -133,6 +133,7 @@ type listQuery struct { vertical string updatedSince time.Time statuses map[string]bool + tiers map[string]bool prelive *bool all bool minTier string @@ -146,7 +147,7 @@ type cursor struct { R int `json:"r,omitempty"` } -var listParams = set("cursor", "status", "promoted", "vertical", "updated_since", "prelive", "order", "limit") +var listParams = set("cursor", "status", "tier", "promoted", "vertical", "updated_since", "prelive", "order", "limit") func parseBool(v string) (*bool, bool) { switch v { @@ -208,12 +209,21 @@ func parseList(r *http.Request, all bool) (listQuery, string, *v1.Error) { default: return q, "", fail(errInvalidParam("status")) } - if !all && st != StatusPublished { + if !all && st != StatusPublished && st != StatusPending { return q, "", fail(errInvalidParam("status")) } q.statuses[st] = true } } + if vals.Has("tier") { + q.tiers = map[string]bool{} + for _, t := range strings.Split(vals.Get("tier"), ",") { + if !qualityTiers[t] { + return q, "", fail(errInvalidParam("tier")) + } + q.tiers[t] = true + } + } if vals.Has("prelive") { b, ok := parseBool(vals.Get("prelive")) if !ok || !all { @@ -223,11 +233,21 @@ func parseList(r *http.Request, all bool) (listQuery, string, *v1.Error) { } h := sha256.Sum256([]byte(strings.Join([]string{ strconv.FormatBool(all), q.order, vals.Get("promoted"), q.vertical, vals.Get("updated_since"), - vals.Get("status"), vals.Get("prelive"), + vals.Get("status"), vals.Get("tier"), vals.Get("prelive"), }, "\x00"))) return q, hex.EncodeToString(h[:8]), nil } +// listable is what articles:read may list: published records, and visible stubs when status asks for them. +func (q *listQuery) listable(p v1.Principal, r *Record) bool { + if q.statuses == nil || q.statuses[StatusPublished] { + if eligible(p, r) { + return true + } + } + return q.statuses[StatusPending] && r.Status == StatusPending && visible(p, r) +} + func (q *listQuery) match(p v1.Principal, r *Record) bool { if q.all { if q.statuses != nil && !q.statuses[r.Status] { @@ -236,7 +256,10 @@ func (q *listQuery) match(p v1.Principal, r *Record) bool { if q.prelive != nil && r.Prelive != *q.prelive { return false } - } else if !eligible(p, r) { + } else if !q.listable(p, r) { + return false + } + if q.tiers != nil && !q.tiers[r.QualityTier] { return false } if q.promoted != nil && promotedAt(r, q.minTier) != *q.promoted { From 82e4f8a17fa62af306fc1b02ad507e746289e405 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 02:00:50 +0300 Subject: [PATCH 006/121] feat(articles): POST /v1/articles/{id}/revert restores a kept version as a new one and reports what it took out --- internal/articles/lifecycle_test.go | 14 ++ internal/articles/put.go | 1 + internal/articles/record.go | 13 ++ internal/articles/revert.go | 272 ++++++++++++++++++++++++++++ internal/articles/revert_test.go | 162 +++++++++++++++++ internal/articles/routes.go | 1 + 6 files changed, 463 insertions(+) create mode 100644 internal/articles/revert.go create mode 100644 internal/articles/revert_test.go diff --git a/internal/articles/lifecycle_test.go b/internal/articles/lifecycle_test.go index 139017e..69db679 100644 --- a/internal/articles/lifecycle_test.go +++ b/internal/articles/lifecycle_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" "strings" "sync" "testing" @@ -40,6 +41,7 @@ func TestRoutesUnavailableUntilRebuilt(t *testing.T) { {synthProd, http.MethodPut, "/v1/articles/" + ulid(2), articleBody("Go memory model")}, {resolverProd, http.MethodDelete, "/v1/articles/" + id, nil}, {dashProd, http.MethodPost, "/v1/articles/" + id + "/status", map[string]any{"action": "hold", "by": "op"}}, + {dashProd, http.MethodPost, "/v1/articles/" + id + "/revert", map[string]any{"to_version": 1, "reason_code": "quality", "by": "op"}}, {goliveAdmin, http.MethodPost, "/v1/articles/purge-prelive", map[string]any{}}, {dashProd, http.MethodGet, "/v1/article-versions/" + id, nil}, {dashProd, http.MethodGet, "/v1/article-versions/" + id + "/1", nil}, @@ -121,6 +123,7 @@ func TestRouteTable(t *testing.T) { mux.Handle("POST /v1/contents", http.NotFoundHandler()) limits := map[string]int64{ "POST /v1/articles/match": 8 << 10, "PUT /v1/articles/{id}": 512 << 10, "POST /v1/articles/{id}/status": 4 << 10, + "POST /v1/articles/{id}/revert": 4 << 10, "POST /v1/articles/purge-prelive": 1 << 10, } for _, rt := range h.s.Routes() { @@ -128,6 +131,16 @@ func TestRouteTable(t *testing.T) { t.Errorf("%s body limit %d", rt.Pattern(), rt.BodyLimit) } } + scopes := map[string][]v1.Scope{ + "PUT /v1/articles/{id}": {v1.ScopeArticlesWrite, v1.ScopeArticlesStub, v1.ScopeArticlesContribute}, + "POST /v1/articles/{id}/revert": {v1.ScopeArticlesModerate}, + "POST /v1/articles/{id}/status": {v1.ScopeArticlesModerate}, + } + for _, rt := range h.s.Routes() { + if want, ok := scopes[rt.Pattern()]; ok && !slices.Equal(rt.Scopes, want) { + t.Errorf("%s scopes %v, want %v", rt.Pattern(), rt.Scopes, want) + } + } probes := map[string]string{ "GET /v1/articles/stats": "GET /v1/articles/stats", "GET /v1/articles/by-slug/versions": "GET /v1/articles/by-slug/{slug}", @@ -137,6 +150,7 @@ func TestRouteTable(t *testing.T) { "POST /v1/articles/purge-prelive": "POST /v1/articles/purge-prelive", "POST /v1/articles/match": "POST /v1/articles/match", "POST /v1/articles/" + ulid(1) + "/status": "POST /v1/articles/{id}/status", + "POST /v1/articles/" + ulid(1) + "/revert": "POST /v1/articles/{id}/revert", } for probe, want := range probes { method, path, _ := strings.Cut(probe, " ") diff --git a/internal/articles/put.go b/internal/articles/put.go index 5274286..f71e5ee 100644 --- a/internal/articles/put.go +++ b/internal/articles/put.go @@ -495,6 +495,7 @@ func (s *Store) planArticle(ix *index, p v1.Principal, id string, b *putBody, ch return nil, fail(errBlocked(p, blocker)) } } + rec.RevertsTo = 0 if contribute { rec.Origin, rec.VersionContributor = originCommunity, &cid if !slices.Contains(rec.Contributors, cid) { diff --git a/internal/articles/record.go b/internal/articles/record.go index 06914f9..715373e 100644 --- a/internal/articles/record.go +++ b/internal/articles/record.go @@ -47,6 +47,15 @@ type Moderation struct { By string `json:"by"` ReasonCode string `json:"reason_code,omitempty"` At string `json:"at"` + *Revert +} + +// Revert is what a revert adds to the record's last moderation action. +type Revert struct { + RequestID string `json:"request_id,omitempty"` + ToVersion int `json:"to_version"` + RevertedVersions []int `json:"reverted_versions"` + RevertedContributors []string `json:"reverted_contributors"` } // Record is an 'R a' value. Its stored shape depends on whether it is an @@ -203,6 +212,10 @@ func (r *Record) clone() *Record { } if r.Moderation != nil { m := *r.Moderation + if m.Revert != nil { + rv := *m.Revert + m.Revert = &rv + } c.Moderation = &m } return &c diff --git a/internal/articles/revert.go b/internal/articles/revert.go new file mode 100644 index 0000000..a780f82 --- /dev/null +++ b/internal/articles/revert.go @@ -0,0 +1,272 @@ +package articles + +import ( + "errors" + "net/http" + "regexp" + "slices" + + "github.com/cockroachdb/pebble" + + v1 "github.com/pilot-protocol/cosift/internal/v1" +) + +var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +type revertBody struct { + ToVersion *int `json:"to_version"` + ReasonCode *string `json:"reason_code"` + By string `json:"by"` + RequestID *string `json:"request_id"` +} + +type revertResult struct { + Result string `json:"result"` + Article writeView `json:"article"` + ToVersion int `json:"to_version"` + RevertedVersions []int `json:"reverted_versions"` + RevertedContributors []string `json:"reverted_contributors"` + Replayed bool `json:"replayed,omitempty"` +} + +func validateRevert(b *revertBody) *v1.Error { + switch { + case b.ToVersion == nil: + return fail(v1.InvalidField("to_version", "required")) + case *b.ToVersion < 1: + return fail(v1.InvalidField("to_version", "range")) + case b.ReasonCode == nil: + return fail(v1.InvalidField("reason_code", "required")) + case !reasonCodes[*b.ReasonCode]: + return fail(v1.InvalidField("reason_code", "enum")) + } + if e := checkText("by", b.By, 1, 64); e != nil { + return e + } + if id := b.RequestID; id != nil { + switch { + case len(*id) < 1 || len(*id) > 64: + return fail(v1.InvalidField("request_id", "length")) + case !requestIDPattern.MatchString(*id): + return fail(v1.InvalidField("request_id", "pattern")) + } + } + return nil +} + +func (s *Store) versionRecord(id string, n int) (*Record, error) { + val, closer, err := s.db.Get(versionKey(id, n)) + if err != nil { + return nil, err + } + defer closer.Close() + return decodeRecord(val) +} + +func (s *Store) handleRevert(w http.ResponseWriter, r *http.Request, p v1.Principal) { + id := r.PathValue("id") + if !validID(id) { + v1.WriteError(w, v1.NotFound()) + return + } + if !allowed(w, r, v1.ScopeArticlesModerate) { + return + } + var b revertBody + if !v1.Decode(w, r, &b, limitStatus) { + return + } + if e := validateRevert(&b); e != nil { + v1.WriteError(w, *e) + return + } + vecs := map[string][]float32{} + if e := s.embedTargetLead(r, id, *b.ToVersion, vecs); e != nil { + v1.WriteError(w, *e) + return + } + for attempt := 0; attempt < 3; attempt++ { + res, status, need, e := s.revertLocked(p, id, &b, vecs) + if e != nil { + v1.WriteError(w, *e) + return + } + if len(need) == 0 { + v1.WriteJSON(w, status, res) + return + } + got, err := s.embed(r.Context(), s.putTimeout, need) + if err != nil { + v1.WriteError(w, errEmbedderUnavailable()) + return + } + for i, t := range need { + vecs[t] = got[i] + } + } + v1.WriteError(w, errEmbedderUnavailable()) +} + +// embedTargetLead embeds the target's lead before the write lock, only when it differs from the stored one. +func (s *Store) embedTargetLead(r *http.Request, id string, to int, vecs map[string][]float32) *v1.Error { + rec := s.record(id) + if rec == nil || rec.Status != StatusPublished || to >= rec.Version { + return nil + } + tgt, err := s.versionRecord(id, to) + if err != nil || tgt.Lead == rec.Lead { + return nil + } + got, err := s.embed(r.Context(), s.putTimeout, []string{tgt.Lead}) + if err != nil { + return fail(errEmbedderUnavailable()) + } + vecs[tgt.Lead] = got[0] + return nil +} + +// revertLocked decides and commits a revert under the write lock; need lists texts whose vectors are missing. +func (s *Store) revertLocked(p v1.Principal, id string, b *revertBody, vecs map[string][]float32) (revertResult, int, []string, *v1.Error) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + a, e := s.activeLocked(p, v1.ScopeArticlesModerate, false) + if e != nil { + return revertResult{}, 0, nil, e + } + stored := s.idx.recs[id] + switch { + case stored == nil: + return revertResult{}, 0, nil, fail(v1.NotFound()) + case !a.SameEnv(stored.Prelive): + return revertResult{}, 0, nil, fail(v1.EnvMismatch()) + } + to := *b.ToVersion + if m := stored.Moderation; m != nil && m.Revert != nil && b.RequestID != nil && m.RequestID == *b.RequestID { + return revertResult{Result: "reverted", Article: stored.written(), ToVersion: m.ToVersion, + RevertedVersions: nonNil(m.RevertedVersions), RevertedContributors: nonNil(m.RevertedContributors), Replayed: true}, http.StatusOK, nil, nil + } + if stored.Status != StatusPublished { + return revertResult{}, 0, nil, fail(errIllegalTransition(stored.Status)) + } + if to >= stored.Version { + return revertResult{}, 0, nil, fail(v1.InvalidField("to_version", "range")) + } + tgt, err := s.versionRecord(id, to) + switch { + case errors.Is(err, pebble.ErrNotFound): + return revertResult{}, 0, nil, fail(v1.NotFound()) + case err != nil: + s.logf("articles: revert target read failed: %v", err) + return revertResult{}, 0, nil, fail(v1.InternalError()) + case tgt.Status != StatusPublished: + return revertResult{}, 0, nil, fail(v1.InvalidField("to_version", "must_be_published")) + case tgt.Sensitivity.Class != "none": + return revertResult{}, 0, nil, fail(v1.InvalidField("to_version", "must_be_none")) + } + cand := stored.clone() + cand.Lead, cand.BodyMD, cand.Citations = tgt.Lead, tgt.BodyMD, slices.Clone(tgt.Citations) + cand.QualityTier, cand.Vertical, cand.Sensitivity = tgt.QualityTier, tgt.Vertical, tgt.Sensitivity + cand.Models, cand.Build = tgt.Models, tgt.Build + cand.ContentSHA256 = contentHash(cand) + if cand.ContentSHA256 == stored.ContentSHA256 { + return revertResult{Result: "unchanged", Article: stored.written(), ToVersion: to, + RevertedVersions: []int{}, RevertedContributors: []string{}}, http.StatusOK, nil, nil + } + versions, contributors, err := s.revertedBy(stored, to) + if err != nil { + s.logf("articles: revert history read failed: %v", err) + return revertResult{}, 0, nil, fail(v1.InternalError()) + } + storedVecs := storedVectors(stored, s.idx.rows[id]) + texts := vectorTexts(cand) + all := make([][]float32, len(texts)) + var need []string + for i, t := range texts { + v, ok := vecs[t] + if !ok { + v, ok = storedVecs[t] + } + if !ok { + need = append(need, t) + continue + } + all[i] = v + } + if len(need) > 0 { + return revertResult{}, 0, need, nil + } + now := s.nowString() + cand.Version, cand.UpdatedAt = stored.Version+1, now + if tgt.VersionContributor != nil { + c := *tgt.VersionContributor + cand.VersionContributor = &c + } else { + cand.VersionContributor = nil + } + cand.RevertsTo = to + rv := &Revert{ToVersion: to, RevertedVersions: nonNil(versions), RevertedContributors: nonNil(contributors)} + if b.RequestID != nil { + rv.RequestID = *b.RequestID + } + cand.Moderation = &Moderation{Action: "revert", By: b.By, ReasonCode: *b.ReasonCode, At: now, Revert: rv} + rows := rowsFor(cand, all) + bt := s.db.NewBatch() + candJSON, _ := cand.MarshalJSON() + prevJSON, _ := stored.MarshalJSON() + _ = bt.Set(recordKey(id), candJSON, nil) + _ = bt.Set(versionKey(id, stored.Version), prevJSON, nil) + model, dim := s.embedderShape() + _ = bt.Set(embeddingKey(id), encodeEmbeddings(model, dim, rows), nil) + if err := s.pruneVersions(bt, id, stored.Version, keepFor(cand)); err != nil { + bt.Close() + return revertResult{}, 0, nil, fail(v1.InternalError()) + } + if err := s.commit(bt); err != nil { + s.logf("articles: revert commit failed: %v", err) + return revertResult{}, 0, nil, fail(v1.InternalError()) + } + s.update(func(ix *index) { + ix.putRecord(cand) + ix.rows[id] = rows + }) + return revertResult{Result: "reverted", Article: cand.written(), ToVersion: to, + RevertedVersions: nonNil(versions), RevertedContributors: nonNil(contributors)}, http.StatusOK, nil, nil +} + +// revertedBy lists the versions after to that a revert to it takes out, and the cids that wrote them. +func (s *Store) revertedBy(cur *Record, to int) ([]int, []string, error) { + revertsTo := map[int]int{} + contributor := map[int]*string{} + for v := to + 1; v <= cur.Version; v++ { + rec := cur + if v < cur.Version { + var err error + if rec, err = s.versionRecord(cur.ID, v); err != nil { + return nil, nil, err + } + } + revertsTo[v], contributor[v] = rec.RevertsTo, rec.VersionContributor + } + var versions []int + var cids []string + for v := to + 1; v <= cur.Version; v++ { + if revertsTo[v] != 0 { + continue + } + takenOut := false + for r := v + 1; r <= cur.Version; r++ { + if revertsTo[r] != 0 && revertsTo[r] < v { + takenOut = true + break + } + } + if takenOut { + continue + } + versions = append(versions, v) + if c := contributor[v]; c != nil && !slices.Contains(cids, *c) { + cids = append(cids, *c) + } + } + return versions, cids, nil +} diff --git a/internal/articles/revert_test.go b/internal/articles/revert_test.go new file mode 100644 index 0000000..a2ee975 --- /dev/null +++ b/internal/articles/revert_test.go @@ -0,0 +1,162 @@ +package articles + +import ( + "net/http" + "slices" + "testing" + "time" +) + +func revertBodyFor(to int, kv ...any) map[string]any { + return with(map[string]any{"to_version": to, "reason_code": "quality", "by": "operator"}, kv...) +} + +func (h *harness) revert(id string, body map[string]any, want int) map[string]any { + h.t.Helper() + return h.call(dashProd, http.MethodPost, "/v1/articles/"+id+"/revert", body, want) +} + +func (h *harness) revertError(id string, body map[string]any, status int, code string) map[string]any { + h.t.Helper() + return h.expectError(dashProd, http.MethodPost, "/v1/articles/"+id+"/revert", body, status, code) +} + +func ints(v any) []int { + out := []int{} + for _, x := range v.([]any) { + out = append(out, int(x.(float64))) + } + return out +} + +func leadOf(n string) string { return "Lead " + n + ". It differs from the others." } + +// history builds v1 synth, v2 A, v3 B, v4 B and returns the id. +func historyABB(h *harness) string { + id := ulid(1) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("one")), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(1), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "lead", leadOf("three"), "expected_version", 2), http.StatusOK) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "lead", leadOf("four"), "expected_version", 3), http.StatusOK) + return id +} + +func TestRevertRestoresContentAndContributor(t *testing.T) { + h := newHarness(t) + id := historyABB(h) + h.clock.advance(time.Hour) + out := h.revert(id, revertBodyFor(2), http.StatusOK) + if out["result"] != "reverted" || out["to_version"] != 2.0 || out["replayed"] != nil { + t.Fatalf("revert = %v", out) + } + if a := article(out); a["version"] != 5.0 || a["status"] != "published" || a["id"] != id { + t.Errorf("article = %v", a) + } + if !slices.Equal(ints(out["reverted_versions"]), []int{3, 4}) || !slices.Equal(toStrings(out["reverted_contributors"]), []string{cid(2)}) { + t.Errorf("lists = %v %v", out["reverted_versions"], out["reverted_contributors"]) + } + got := fullOf(h, id) + if got["lead"] != leadOf("two") || got["version"] != 5.0 || got["version_contributor"] != cid(1) || got["reverts_to"] != 2.0 { + t.Errorf("record = lead %q v%v contributor %v reverts_to %v", got["lead"], got["version"], got["version_contributor"], got["reverts_to"]) + } + if got["origin"] != "community" || !slices.Equal(toStrings(got["contributors"]), []string{cid(1), cid(2)}) { + t.Errorf("attribution = %v %v", got["origin"], got["contributors"]) + } + m := got["moderation"].(map[string]any) + if m["action"] != "revert" || m["by"] != "operator" || m["reason_code"] != "quality" || m["to_version"] != 2.0 || !slices.Equal(ints(m["reverted_versions"]), []int{3, 4}) { + t.Errorf("moderation = %v", m) + } + if got["updated_at"] == got["created_at"] || got["status_changed_at"] != got["created_at"] { + t.Errorf("timestamps: updated %v status_changed %v created %v", got["updated_at"], got["status_changed_at"], got["created_at"]) + } + if pv := h.call(dashProd, http.MethodGet, "/v1/article-versions/"+id+"/4", nil, http.StatusOK); pv["lead"] != leadOf("four") { + t.Errorf("the version taken out was lost: %v", pv["lead"]) + } + pub := h.call(wiki, http.MethodGet, "/v1/articles/"+id, nil, http.StatusOK) + if pub["lead"] != leadOf("two") || hasKey(pub, "reverts_to") || hasKey(pub, "version_contributor") { + t.Errorf("public = %v", pub) + } +} + +func TestRevertListsFollowTheContractTable(t *testing.T) { + h := newHarness(t) + id := historyABB(h) + h.revert(id, revertBodyFor(2), http.StatusOK) + h.contribute(id, contribBody("Rust async runtimes", cid(3), "lead", leadOf("six"), "expected_version", 5), http.StatusOK) + out := h.revert(id, revertBodyFor(1), http.StatusOK) + if a := article(out); a["version"] != 7.0 { + t.Fatalf("version = %v", a["version"]) + } + if !slices.Equal(ints(out["reverted_versions"]), []int{2, 6}) || !slices.Equal(toStrings(out["reverted_contributors"]), []string{cid(1), cid(3)}) { + t.Errorf("lists = %v %v", out["reverted_versions"], out["reverted_contributors"]) + } + got := fullOf(h, id) + if v, ok := got["version_contributor"]; !ok || v != nil || got["reverts_to"] != 1.0 || got["lead"] != leadOf("one") { + t.Errorf("record = contributor %v reverts_to %v lead %q", got["version_contributor"], got["reverts_to"], got["lead"]) + } + if len(got["contributors"].([]any)) != 3 { + t.Errorf("contributors shrank: %v", got["contributors"]) + } +} + +func TestRevertToARevertCreatedVersionHasEmptyLists(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("one")), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(1), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.revert(id, revertBodyFor(1), http.StatusOK) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "lead", leadOf("four"), "expected_version", 3), http.StatusOK) + out := h.revert(id, revertBodyFor(3), http.StatusOK) + if out["result"] != "reverted" || len(ints(out["reverted_versions"])) != 1 || ints(out["reverted_versions"])[0] != 4 { + t.Errorf("revert to the revert-created version = %v", out) + } + out = h.revert(id, revertBodyFor(2), http.StatusOK) + if !slices.Equal(ints(out["reverted_versions"]), []int{}) || len(toStrings(out["reverted_contributors"])) != 0 || out["result"] != "reverted" { + t.Errorf("lists = %v %v result %v", out["reverted_versions"], out["reverted_contributors"], out["result"]) + } +} + +func TestRevertUnchangedWhenContentEqualsTarget(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("one")), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(1), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "lead", leadOf("one"), "expected_version", 2), http.StatusOK) + before := fullOf(h, id) + out := h.revert(id, revertBodyFor(1), http.StatusOK) + if out["result"] != "unchanged" || len(ints(out["reverted_versions"])) != 0 || len(toStrings(out["reverted_contributors"])) != 0 || article(out)["version"] != 3.0 { + t.Errorf("revert = %v", out) + } + if after := fullOf(h, id); after["version"] != before["version"] || after["version_contributor"] != before["version_contributor"] || after["moderation"] != nil { + t.Errorf("unchanged revert wrote: %v", after) + } +} + +func TestRevertReplayAndRequestID(t *testing.T) { + h := newHarness(t) + id := historyABB(h) + first := h.revert(id, revertBodyFor(2, "request_id", id+"_01J9D6T1F7G8H9J0K1M2N3P4Q5"), http.StatusOK) + if first["replayed"] != nil { + t.Fatalf("first = %v", first) + } + replay := h.revert(id, revertBodyFor(2, "request_id", id+"_01J9D6T1F7G8H9J0K1M2N3P4Q5"), http.StatusOK) + if replay["result"] != "reverted" || replay["replayed"] != true || !slices.Equal(ints(replay["reverted_versions"]), []int{3, 4}) || !slices.Equal(toStrings(replay["reverted_contributors"]), []string{cid(2)}) || article(replay)["version"] != 5.0 { + t.Errorf("replay = %v", replay) + } + if fullOf(h, id)["version"] != 5.0 { + t.Error("a replay wrote a version") + } + other := h.revert(id, revertBodyFor(2, "request_id", "another"), http.StatusOK) + if other["result"] != "unchanged" || len(ints(other["reverted_versions"])) != 0 { + t.Errorf("other request id = %v", other) + } + none := h.revert(id, revertBodyFor(2), http.StatusOK) + if none["result"] != "unchanged" || none["replayed"] != nil { + t.Errorf("no request id = %v", none) + } + h.moderate(dashProd, id, "hold", "", http.StatusOK) + h.moderate(dashProd, id, "approve", "", http.StatusOK) + if again := h.revert(id, revertBodyFor(2, "request_id", id+"_01J9D6T1F7G8H9J0K1M2N3P4Q5"), http.StatusOK); again["replayed"] != nil { + t.Errorf("replay survived a later moderation action: %v", again) + } +} diff --git a/internal/articles/routes.go b/internal/articles/routes.go index 617008e..7279787 100644 --- a/internal/articles/routes.go +++ b/internal/articles/routes.go @@ -31,6 +31,7 @@ func (s *Store) Routes() []v1.Route { {Method: http.MethodPut, Path: "/v1/articles/{id}", Scopes: []v1.Scope{v1.ScopeArticlesWrite, v1.ScopeArticlesStub, v1.ScopeArticlesContribute}, BodyLimit: limitPut, Handler: s.gated(s.handlePut)}, {Method: http.MethodDelete, Path: "/v1/articles/{id}", Scopes: []v1.Scope{v1.ScopeArticlesStub}, Handler: s.gated(s.handleDeleteStub)}, {Method: http.MethodPost, Path: "/v1/articles/{id}/status", Scopes: []v1.Scope{v1.ScopeArticlesModerate}, BodyLimit: limitStatus, Handler: s.gated(s.handleStatus)}, + {Method: http.MethodPost, Path: "/v1/articles/{id}/revert", Scopes: []v1.Scope{v1.ScopeArticlesModerate}, BodyLimit: limitStatus, Handler: s.gated(s.handleRevert)}, {Method: http.MethodPost, Path: "/v1/articles/purge-prelive", Scopes: []v1.Scope{v1.ScopeArticlesAdmin}, BodyLimit: limitPurge, Handler: s.gated(s.handlePurge)}, {Method: http.MethodGet, Path: "/v1/article-versions/{id}", Scopes: []v1.Scope{readAll}, Handler: s.gated(s.handleVersions)}, {Method: http.MethodGet, Path: "/v1/article-versions/{id}/{version}", Scopes: []v1.Scope{readAll}, Handler: s.gated(s.handleVersion)}, From a05f194603f2e370d79ef1d14d49af6216cd4d3c Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 02:00:50 +0300 Subject: [PATCH 007/121] test(articles): revert body, target, environment, replay, pruning and embedding rules --- internal/articles/revert_rules_test.go | 343 +++++++++++++++++++++++++ internal/articles/revert_test.go | 75 ++++++ 2 files changed, 418 insertions(+) create mode 100644 internal/articles/revert_rules_test.go diff --git a/internal/articles/revert_rules_test.go b/internal/articles/revert_rules_test.go new file mode 100644 index 0000000..c3f7c5c --- /dev/null +++ b/internal/articles/revert_rules_test.go @@ -0,0 +1,343 @@ +package articles + +import ( + "encoding/json" + "fmt" + "net/http" + "slices" + "testing" + "time" +) + +func TestRevertRefusedByStatus(t *testing.T) { + h := newHarness(t) + h.call(resolverProd, http.MethodPut, "/v1/articles/"+ulid(9), stubBodyFor("Stub title here", topic(9)), http.StatusCreated) + if out := h.revertError(ulid(9), revertBodyFor(1), 409, "illegal_transition"); out["current_status"] != "pending" { + t.Errorf("pending = %v", out) + } + for i, action := range []string{"hold", "reject", "tombstone"} { + id := ulid(i + 1) + title := []string{"Rust async runtimes", "Go memory model", "Kafka consumer groups"}[i] + h.put(synthProd, id, articleBody(title), http.StatusCreated) + h.put(synthProd, id, with(articleBody(title), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.moderate(dashProd, id, action, "quality", http.StatusOK) + want := map[string]string{"hold": "held", "reject": "rejected", "tombstone": "tombstoned"}[action] + if out := h.revertError(id, revertBodyFor(1), 409, "illegal_transition"); out["current_status"] != want { + t.Errorf("%s: %v", action, out) + } + } + h.put(synthStaging, ulid(5), articleBody("Staging rejected title"), http.StatusCreated) + h.put(synthStaging, ulid(5), with(articleBody("Staging rejected title"), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.call(dashStaging, http.MethodPost, "/v1/articles/"+ulid(5)+"/status", map[string]any{"action": "reject", "reason_code": "quality", "by": "op"}, http.StatusOK) + closeStagingWrites(h) + purge(h, map[string]any{"apply": true, "expect_records": 1}, http.StatusOK) + if out := h.revertError(ulid(5), revertBodyFor(1), 409, "illegal_transition"); out["current_status"] != "rejected" { + t.Errorf("converted residue = %v", out) + } + h.revertError(ulid(404), revertBodyFor(1), 404, "not_found") + h.expectError(dashProd, http.MethodPost, "/v1/articles/not-an-id/revert", revertBodyFor(1), 404, "not_found") +} + +func TestRevertTargetRules(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "status", "held", "lead", leadOf("held build")), http.StatusCreated) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "status", "held", "lead", leadOf("held rebuild"), "expected_version", 1), http.StatusOK) + h.moderate(dashProd, id, "approve", "", http.StatusOK) + h.contribute(id, contribBody("Rust async runtimes", cid(1), "lead", leadOf("community"), "expected_version", 2), http.StatusOK) + before := fullOf(h, id) + wantInvalid(t, h.revertError(id, revertBodyFor(1), 422, "invalid_field"), "to_version", "must_be_published") + wantInvalid(t, h.revertError(id, revertBodyFor(3), 422, "invalid_field"), "to_version", "range") + wantInvalid(t, h.revertError(id, revertBodyFor(9), 422, "invalid_field"), "to_version", "range") + if after := fullOf(h, id); after["version"] != before["version"] || after["lead"] != before["lead"] { + t.Error("a refused revert wrote") + } + if out := h.revert(id, revertBodyFor(2), http.StatusOK); out["result"] != "reverted" { + t.Errorf("the approved build was refused: %v", out) + } + + sid := ulid(2) + sensitive := map[string]any{"class": "medical_advice", "reason": "dosage"} + h.put(synthProd, sid, with(articleBody("Go memory model"), "status", "held", "sensitivity", sensitive), http.StatusCreated) + h.moderate(dashProd, sid, "approve", "", http.StatusOK) + h.put(synthProd, sid, with(articleBody("Go memory model"), "lead", leadOf("clean rebuild"), "expected_version", 1), http.StatusOK) + wantInvalid(t, h.revertError(sid, revertBodyFor(1), 422, "invalid_field"), "to_version", "must_be_none") + if fullOf(h, sid)["version"] != 2.0 { + t.Error("a refused revert wrote") + } +} + +func TestRevertPrunedTargetIsNotFound(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, articleBody("Rust async runtimes"), http.StatusCreated) + cur := synthRevise(h, id, 8, 1) + h.revertError(id, revertBodyFor(1), 404, "not_found") + if out := h.revert(id, revertBodyFor(cur-5), http.StatusOK); out["result"] != "reverted" { + t.Errorf("oldest kept version = %v", out) + } +} + +func TestRevertBodyValidation(t *testing.T) { + h := newHarness(t) + id := historyABB(h) + long := make([]byte, 65) + for i := range long { + long[i] = 'a' + } + cases := []struct { + name string + body map[string]any + status int + code string + field, rule string + }{ + {"to_version missing", with(revertBodyFor(2), "to_version", nil), 422, "invalid_field", "to_version", "required"}, + {"to_version zero", revertBodyFor(0), 422, "invalid_field", "to_version", "range"}, + {"to_version string", revertBodyFor(2, "to_version", "2"), 400, "invalid_body", "", ""}, + {"to_version fraction", revertBodyFor(2, "to_version", 1.5), 400, "invalid_body", "", ""}, + {"reason_code missing", with(revertBodyFor(2), "reason_code", nil), 422, "invalid_field", "reason_code", "required"}, + {"reason_code unknown", revertBodyFor(2, "reason_code", "boredom"), 422, "invalid_field", "reason_code", "enum"}, + {"by missing", with(revertBodyFor(2), "by", nil), 422, "invalid_field", "by", "length"}, + {"by too long", revertBodyFor(2, "by", string(long)), 422, "invalid_field", "by", "length"}, + {"request_id empty", revertBodyFor(2, "request_id", ""), 422, "invalid_field", "request_id", "length"}, + {"request_id too long", revertBodyFor(2, "request_id", string(long)), 422, "invalid_field", "request_id", "length"}, + {"request_id characters", revertBodyFor(2, "request_id", "a b"), 422, "invalid_field", "request_id", "pattern"}, + {"unknown field", revertBodyFor(2, "action", "revert"), 400, "unknown_field", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := h.revertError(id, tc.body, tc.status, tc.code) + if tc.field != "" { + wantInvalid(t, out, tc.field, tc.rule) + } + }) + } + if fullOf(h, id)["version"] != 4.0 { + t.Error("a refused revert wrote") + } + h.expectError(synthProd, http.MethodPost, "/v1/articles/"+id+"/revert", revertBodyFor(2), http.StatusForbidden, "missing_scope") + if rec := h.do(dashProd, http.MethodPost, "/v1/articles/"+id+"/revert", "not json"); rec.Code != http.StatusBadRequest { + t.Errorf("not JSON: %d", rec.Code) + } +} + +func TestRevertKeepsAliasesClaimsAndIgnoresCorpus(t *testing.T) { + h := newHarness(t) + id := ulid(1) + body := with(articleBody("Rust async runtimes"), "aliases", []string{"Async executors in Rust"}, "topic_ids", []string{topic(1)}) + h.put(synthProd, id, body, http.StatusCreated) + h.put(synthProd, id, with(body, "lead", leadOf("two"), "aliases", []string{"Tokio and friends"}, "topic_ids", []string{topic(2)}, "expected_version", 1), http.StatusOK) + delete(h.corpus.urls, "https://example.org/doc") + out := h.revert(id, revertBodyFor(1), http.StatusOK) + if out["result"] != "reverted" { + t.Fatalf("revert = %v", out) + } + got := fullOf(h, id) + if !slices.Equal(toStrings(got["aliases"]), []string{"Async executors in Rust", "Tokio and friends"}) || !slices.Equal(toStrings(got["topic_ids"]), []string{topic(1), topic(2)}) { + t.Errorf("aliases %v topics %v", got["aliases"], got["topic_ids"]) + } + for _, tp := range []string{topic(1), topic(2)} { + if owner, ok := h.raw(topicKey(tp)); !ok || string(owner) != id { + t.Errorf("claim %s lost", tp) + } + } + if got["title"] != "Rust async runtimes" || got["slug"] != "rust-async-runtimes" { + t.Errorf("title/slug = %v %v", got["title"], got["slug"]) + } +} + +func TestRevertReembedsTheLeadAndKeepsTitleVectors(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.emb.set("Rust async runtimes", axis(1)) + h.emb.set(leadOf("one"), axis(2)) + h.emb.set(leadOf("two"), axis(3)) + h.emb.set("q lead one", axis(2)) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("one")), http.StatusCreated) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + titleCalls, leadCalls := h.emb.count("Rust async runtimes"), h.emb.count(leadOf("one")) + if out := h.match(mcpProd, map[string]any{"q": "q lead one"}, http.StatusOK); out["verdict"] == "covered" && out["match"] == "embedding" && out["score"].(float64) > 0.99 { + t.Fatalf("setup: the old lead already matches: %v", out) + } + h.revert(id, revertBodyFor(1), http.StatusOK) + if h.emb.count("Rust async runtimes") != titleCalls { + t.Error("the title was embedded again") + } + if h.emb.count(leadOf("one")) != leadCalls+1 { + t.Errorf("the restored lead was embedded %d times", h.emb.count(leadOf("one"))-leadCalls) + } + if out := h.match(mcpProd, map[string]any{"q": "q lead one"}, http.StatusOK); out["verdict"] != "covered" || out["score"].(float64) < 0.99 { + t.Errorf("match after the revert = %v", out) + } + h.restart() + if out := h.match(mcpProd, map[string]any{"q": "q lead one"}, http.StatusOK); out["verdict"] != "covered" { + t.Errorf("match after a restart = %v", out) + } +} + +func TestRevertEmbedderFailureWritesNothing(t *testing.T) { + h := newHarness(t) + id := historyABB(h) + before := fullOf(h, id) + h.emb.setFailIf(func([]string) bool { return true }) + out := h.revertError(id, revertBodyFor(2), 503, "embedder_unavailable") + _ = out + after := fullOf(h, id) + if after["version"] != before["version"] || after["lead"] != before["lead"] { + t.Errorf("a failed revert wrote: v%v", after["version"]) + } + if _, ok := h.raw(versionKey(id, 4)); ok { + t.Error("a failed revert wrote a version row") + } + h.emb.setFailIf(nil) + h.revert(id, revertBodyFor(2), http.StatusOK) +} + +func TestRevertEnvironmentRules(t *testing.T) { + h := newHarness(t) + h.put(synthProd, ulid(1), articleBody("Rust async runtimes"), http.StatusCreated) + h.put(synthProd, ulid(1), with(articleBody("Rust async runtimes"), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.put(synthStaging, ulid(2), articleBody("Go memory model"), http.StatusCreated) + h.put(synthStaging, ulid(2), with(articleBody("Go memory model"), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.expectError(dashStaging, http.MethodPost, "/v1/articles/"+ulid(1)+"/revert", revertBodyFor(1), http.StatusForbidden, "env_mismatch") + h.expectError(dashProd, http.MethodPost, "/v1/articles/"+ulid(2)+"/revert", revertBodyFor(1), http.StatusForbidden, "env_mismatch") + h.call(dashStaging, http.MethodPost, "/v1/articles/"+ulid(2)+"/revert", revertBodyFor(1), http.StatusOK) + closeStagingWrites(h) + purge(h, map[string]any{"apply": true, "expect_records": 1}, http.StatusOK) + h.expectError(dashStaging, http.MethodPost, "/v1/articles/"+ulid(1)+"/revert", revertBodyFor(1), http.StatusForbidden, "env_golive") +} + +func TestRevertWorksWhileWritesAreFrozenAndRestorePending(t *testing.T) { + h := newHarness(t) + id := historyABB(h) + h.pol.SetWritesFrozen(true) + h.pol.SetWriteBudget("dash-prod", 0, time.Hour) + if out := h.revert(id, revertBodyFor(2), http.StatusOK); out["result"] != "reverted" { + t.Errorf("frozen revert = %v", out) + } + if n := h.pol.Charges("dash-prod"); n != 0 { + t.Errorf("revert charged %d writes", n) + } + h.pol.SetGoliveAt(time.Date(2026, 10, 12, 16, 0, 0, 0, time.UTC)) + if out := h.revert(id, revertBodyFor(1, "request_id", "pending-1"), http.StatusOK); out["result"] != "reverted" { + t.Errorf("revert during restore_pending = %v", out) + } +} + +func TestRevertPrunesByTheKeepRule(t *testing.T) { + h := newHarness(t) + synth := ulid(1) + h.put(synthProd, synth, articleBody("Rust async runtimes"), http.StatusCreated) + cur := synthRevise(h, synth, 6, 1) + h.revert(synth, revertBodyFor(cur-2), http.StatusOK) + if got := keptVersions(h, synth); !slices.Equal(got, topRange(cur-4, cur)) { + t.Errorf("synth kept %v at %d", got, cur+1) + } + + comm := ulid(2) + h.contribute(comm, contribBody("Rust async runtimes", cid(0)), http.StatusCreated) + cur = contribRevise(h, comm, 22, 1) + h.revert(comm, revertBodyFor(cur-3), http.StatusOK) + if got := keptVersions(h, comm); !slices.Equal(got, topRange(cur+1-20, cur)) { + t.Errorf("community kept %v at %d", got, cur+1) + } + for _, v := range keptVersions(h, comm) { + h.call(dashProd, http.MethodGet, fmt.Sprintf("/v1/article-versions/%s/%d", comm, v), nil, http.StatusOK) + } +} + +func TestLaterWriteClearsRevertsTo(t *testing.T) { + h := newHarness(t) + id := historyABB(h) + h.revert(id, revertBodyFor(2), http.StatusOK) + if fullOf(h, id)["reverts_to"] != 2.0 { + t.Fatal("setup: no reverts_to") + } + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("synth after"), "expected_version", 5), http.StatusOK) + if got := fullOf(h, id); hasKey(got, "reverts_to") { + t.Errorf("reverts_to survived a later write: %v", got["reverts_to"]) + } +} + +func TestRevertRestoresEveryContentField(t *testing.T) { + h := newHarness(t) + id := ulid(1) + first := articleBody("Rust async runtimes") + h.put(synthProd, id, first, http.StatusCreated) + changed := with(first, "body_md", "## Overview\n\nA different body. [1]\n\n## Key facts\n\n- Another fact [1]\n"+first["body_md"].(string)[len("## Overview\n\n"):], + "citations", []any{map[string]any{"n": 1, "url": "https://example.org/other", "title": "Other", "host": "example.org", "quote": "Another quote.", "content_sha": "b" + first["citations"].([]any)[0].(map[string]any)["content_sha"].(string)[1:]}}, + "quality_tier", "ok", "vertical", "research", + "models", map[string]any{"triage": "triage-two", "writer": "writer-two"}, + "build", map[string]any{"sources_considered": 9, "sources_used": 3, "retrieval_params": map[string]any{"bm25_k": 10}, "cost_usd": 0.5, "dense_degraded": true}, + "expected_version", 1) + h.put(synthProd, id, changed, http.StatusOK) + if out := h.revert(id, revertBodyFor(1), http.StatusOK); out["result"] != "reverted" { + t.Fatalf("revert = %v", out) + } + got, want := fullOf(h, id), h.call(dashProd, http.MethodGet, "/v1/article-versions/"+id+"/1", nil, http.StatusOK) + for _, k := range []string{"lead", "body_md", "citations", "quality_tier", "vertical", "sensitivity", "models", "build"} { + a, _ := json.Marshal(got[k]) + b, _ := json.Marshal(want[k]) + if string(a) != string(b) { + t.Errorf("%s = %s, want %s", k, a, b) + } + } + if got["content_sha256"] != want["content_sha256"] { + t.Error("content hash differs from the restored version's") + } + for _, only := range []string{"body_md", "citations", "quality_tier", "vertical"} { + h2 := newHarness(t) + h2.put(synthProd, id, first, http.StatusCreated) + h2.put(synthProd, id, with(first, only, changed[only], "expected_version", 1), http.StatusOK) + if out := h2.revert(id, revertBodyFor(1), http.StatusOK); out["result"] != "reverted" { + t.Errorf("a difference in %s alone was treated as unchanged: %v", only, out) + } + } +} + +func TestRevertEmbedsOutsideTheWriteLock(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("one")), http.StatusCreated) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.s.writeMu.Lock() + done := make(chan map[string]any) + go func() { + rec := h.do(dashProd, http.MethodPost, "/v1/articles/"+id+"/revert", revertBodyFor(1)) + done <- map[string]any{"code": rec.Code} + }() + waitFor(t, func() bool { return h.emb.embedded(leadOf("one")) }) + h.s.writeMu.Unlock() + if out := <-done; out["code"] != http.StatusOK { + t.Errorf("revert = %v", out) + } +} + +func TestRevertChecksScopeBeforeBody(t *testing.T) { + h := newHarness(t) + id := historyABB(h) + h.expectError(synthProd, http.MethodPost, "/v1/articles/"+id+"/revert", map[string]any{"to_version": "x"}, http.StatusForbidden, "missing_scope") + h.expectError(wiki, http.MethodPost, "/v1/articles/"+id+"/revert", "not json", http.StatusForbidden, "missing_scope") +} + +func TestRevertEmbedsOnlyWhenTheLeadDiffers(t *testing.T) { + h := newHarness(t) + id := ulid(1) + first := with(articleBody("Rust async runtimes"), "lead", leadOf("one")) + h.put(synthProd, id, first, http.StatusCreated) + h.put(synthProd, id, with(first, "body_md", "## Overview\n\nA different body. [1]\n\n## Key facts\n\n- Another fact [1]\n"+first["body_md"].(string)[len("## Overview\n\n"):], "expected_version", 1), http.StatusOK) + h.emb.setFailIf(func([]string) bool { return true }) + calls := len(h.emb.calls) + if out := h.revert(id, revertBodyFor(1), http.StatusOK); out["result"] != "reverted" { + t.Fatalf("revert = %v", out) + } + if len(h.emb.calls) != calls { + t.Errorf("a revert with the same lead embedded %d times", len(h.emb.calls)-calls) + } + h.put(synthProd, id, with(first, "lead", leadOf("two"), "expected_version", 3), http.StatusServiceUnavailable) + h.emb.setFailIf(nil) + h.put(synthProd, id, with(first, "lead", leadOf("two"), "expected_version", 3), http.StatusOK) + h.emb.setFailIf(func([]string) bool { return true }) + h.expectError(dashStaging, http.MethodPost, "/v1/articles/"+id+"/revert", revertBodyFor(1), http.StatusServiceUnavailable, "embedder_unavailable") +} diff --git a/internal/articles/revert_test.go b/internal/articles/revert_test.go index a2ee975..ee9f9ba 100644 --- a/internal/articles/revert_test.go +++ b/internal/articles/revert_test.go @@ -160,3 +160,78 @@ func TestRevertReplayAndRequestID(t *testing.T) { t.Errorf("replay survived a later moderation action: %v", again) } } + +func TestRevertReplayComesBeforeIllegalTransitionAndAfterEnvMismatch(t *testing.T) { + h := newHarness(t) + id := historyABB(h) + rid := id + "_01J9D6T1F7G8H9J0K1M2N3P4Q5" + h.revert(id, revertBodyFor(2, "request_id", rid), http.StatusOK) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "status", "held", "lead", leadOf("rebuilt"), "expected_version", 5), http.StatusOK) + if got := fullOf(h, id); got["status"] != "held" { + t.Fatalf("setup: status %v", got["status"]) + } + replay := h.revert(id, revertBodyFor(2, "request_id", rid), http.StatusOK) + if replay["replayed"] != true || !slices.Equal(ints(replay["reverted_versions"]), []int{3, 4}) || !slices.Equal(toStrings(replay["reverted_contributors"]), []string{cid(2)}) { + t.Errorf("replay on a held record = %v", replay) + } + h.revertError(id, revertBodyFor(2, "request_id", "another"), http.StatusConflict, "illegal_transition") + h.expectError(dashStaging, http.MethodPost, "/v1/articles/"+id+"/revert", revertBodyFor(2, "request_id", rid), http.StatusForbidden, "env_mismatch") +} + +func TestRevertKeepsOriginCommunityWhenTheTargetIsSynth(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("one")), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(1), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.revert(id, revertBodyFor(1), http.StatusOK) + h.restart() + if got := fullOf(h, id); got["origin"] != "community" || got["lead"] != leadOf("one") { + t.Errorf("full origin %v lead %q", got["origin"], got["lead"]) + } + if pub := h.call(wiki, http.MethodGet, "/v1/articles/"+id, nil, http.StatusOK); pub["origin"] != "community" { + t.Errorf("public origin %v", pub["origin"]) + } +} + +func TestRevertCreatedVersionsDoNotListTheirInheritedContributor(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(1), "lead", leadOf("one")), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.revert(id, revertBodyFor(1), http.StatusOK) + if got := fullOf(h, id); got["version"] != 3.0 || got["version_contributor"] != cid(1) { + t.Fatalf("setup: v%v contributor %v", got["version"], got["version_contributor"]) + } + out := h.revert(id, revertBodyFor(2), http.StatusOK) + if out["result"] != "reverted" || len(ints(out["reverted_versions"])) != 0 || len(toStrings(out["reverted_contributors"])) != 0 { + t.Errorf("revert over a revert-created version = %v", out) + } +} + +func TestRevertThatTookNothingOutStillReplays(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.contribute(id, contribBody("Rust async runtimes", cid(1), "lead", leadOf("one")), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.revert(id, revertBodyFor(1), http.StatusOK) + first := h.revert(id, revertBodyFor(2, "request_id", "empty-1"), http.StatusOK) + if first["result"] != "reverted" || len(ints(first["reverted_versions"])) != 0 { + t.Fatalf("first = %v", first) + } + replay := h.revert(id, revertBodyFor(2, "request_id", "empty-1"), http.StatusOK) + if replay["result"] != "reverted" || replay["replayed"] != true || len(ints(replay["reverted_versions"])) != 0 || article(replay)["version"] != 4.0 { + t.Errorf("replay = %v", replay) + } +} + +func TestRevertStoresEmptyListsAsArrays(t *testing.T) { + h := newHarness(t) + id := ulid(1) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("one")), http.StatusCreated) + h.put(synthProd, id, with(articleBody("Rust async runtimes"), "lead", leadOf("two"), "expected_version", 1), http.StatusOK) + h.revert(id, revertBodyFor(1, "request_id", "empty-1"), http.StatusOK) + m := fullOf(h, id)["moderation"].(map[string]any) + if cs, ok := m["reverted_contributors"].([]any); !ok || len(cs) != 0 { + t.Errorf("stored reverted_contributors = %#v", m["reverted_contributors"]) + } +} From 6d36adc1b9f890db62c9f3ecfcd3501db66748d5 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 02:13:10 +0300 Subject: [PATCH 008/121] test(cosift): contributions, reverts and the wanted-list filters through the /v1 listener, and the corpus counts they leave alone --- cmd/cosift/serve_v1_contribute_test.go | 127 +++++++++++++++++++++++++ cmd/cosift/serve_v1_joint_test.go | 10 +- internal/store/pebble.go | 2 +- 3 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 cmd/cosift/serve_v1_contribute_test.go diff --git a/cmd/cosift/serve_v1_contribute_test.go b/cmd/cosift/serve_v1_contribute_test.go new file mode 100644 index 0000000..d94ad33 --- /dev/null +++ b/cmd/cosift/serve_v1_contribute_test.go @@ -0,0 +1,127 @@ +package main + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" +) + +const jointCid = "0f1e2d3c4b5a6978" + +func jointContribution(title, cid string, kv ...any) map[string]any { + b := jointArticle(title) + b["contributor"] = cid + for i := 0; i+1 < len(kv); i += 2 { + b[kv[i].(string)] = kv[i+1] + } + return b +} + +func corpusCounts(t *testing.T, j *joint) map[string]any { + t.Helper() + body, err := j.srv.buildStatsBody(context.Background()) + if err != nil { + t.Fatal(err) + } + var all map[string]any + if err := json.Unmarshal(body, &all); err != nil { + t.Fatal(err) + } + out := map[string]any{} + for _, k := range []string{"documents", "indexed_docs", "sum_doc_len", "avg_doc_len", "terms"} { + out[k] = all[k] + } + return out +} + +func TestJointContributionAndRevert(t *testing.T) { + j := newJoint(t, nil, nil) + counts := corpusCounts(t, j) + id := jointID(1) + + j.want(j.do("intake-prod", "PUT", "/v1/articles/"+id, jointContribution("Raft consensus explained", jointCid)), 201, "") + j.want(j.do("intake-prod", "PUT", "/v1/articles/"+id, jointContribution("Raft consensus explained", jointCid)), 200, "") + r := j.do("intake-prod", "PUT", "/v1/articles/"+id, jointContribution("Raft consensus explained", "ffffffffffffffff", "lead", "A revised lead. It differs.", "expected_version", 1)) + j.want(r, 200, "") + if r.body["result"] != "updated" { + t.Fatalf("update = %s", r.raw) + } + + full := j.do("dash-prod", "GET", "/v1/articles/"+id, nil) + j.want(full, 200, "") + if full.body["origin"] != "community" || full.body["version_contributor"] != "ffffffffffffffff" || len(full.body["contributors"].([]any)) != 2 { + t.Fatalf("full = %s", full.raw) + } + pub := j.do("community-wiki", "GET", "/v1/articles/"+id, nil) + j.want(pub, 200, "") + if pub.body["origin"] != "community" || strings.Contains(pub.raw, "version_contributor") || strings.Contains(pub.raw, "reverts_to") { + t.Fatalf("public = %s", pub.raw) + } + + j.want(j.do("synth-prod", "PUT", "/v1/articles/"+id, jointContribution("Raft consensus explained", jointCid)), 422, "invalid_field") + j.want(j.do("mcp-prod", "PUT", "/v1/articles/"+id, jointContribution("Raft consensus explained", jointCid)), 403, "missing_scope") + j.want(j.do("intake-prod", "PUT", "/v1/articles/"+jointID(2), jointStub("Contributors write no stubs", "8624225e00000001")), 403, "missing_scope") + j.want(j.do("intake-prod", "POST", "/v1/articles/"+id+"/revert", map[string]any{"to_version": 1, "reason_code": "quality", "by": "x"}), 403, "missing_scope") + j.want(j.do("community-wiki", "POST", "/v1/articles/"+id+"/revert", map[string]any{"to_version": 1, "reason_code": "quality", "by": "x"}), 403, "missing_scope") + j.want(j.do("intake-prod", "POST", "/v1/articles/"+id+"/status", moderation("hold")), 403, "missing_scope") + j.want(j.do("intake-prod", "POST", "/v1/articles/match", map[string]any{"q": "Raft consensus explained", "purpose": "build", "topic_ids": []string{"8624225e00000002"}}), 200, "") + + rv := j.do("dash-prod", "POST", "/v1/articles/"+id+"/revert", map[string]any{"to_version": 1, "reason_code": "quality", "by": "operator", "request_id": id + "_01J9D6T1F7G8H9J0K1M2N3P4Q5"}) + j.want(rv, 200, "") + if rv.body["result"] != "reverted" || !slices.Equal(anyStrings(rv.body["reverted_contributors"]), []string{"ffffffffffffffff"}) { + t.Fatalf("revert = %s", rv.raw) + } + tomb := j.do("dash-prod", "POST", "/v1/articles/"+id+"/status", moderation("tombstone")) + j.want(tomb, 200, "") + if !slices.Equal(anyStrings(tomb.body["contributors"]), []string{jointCid, "ffffffffffffffff"}) { + t.Fatalf("tombstone = %s", tomb.raw) + } + + if got := corpusCounts(t, j); !equalJSON(got, counts) { + t.Fatalf("/stats counts moved: %v → %v", counts, got) + } + if v := j.metric(`cosift_v1_writes_total{principal="intake-prod"}`); v != "3" { + t.Fatalf("intake writes charged = %s", v) + } +} + +func TestJointWantedListFilters(t *testing.T) { + j := newJoint(t, nil, nil) + j.want(j.do("resolver-prod", "PUT", "/v1/articles/"+jointID(1), jointStub("Wanted stub title", "8624225e00000001")), 201, "") + j.want(j.do("synth-prod", "PUT", "/v1/articles/"+jointID(2), jointArticle("Published strong title")), 201, "") + thin := jointArticle("Published thin title") + thin["quality_tier"] = "thin" + j.want(j.do("synth-prod", "PUT", "/v1/articles/"+jointID(3), thin), 201, "") + for _, c := range []struct { + who, query string + want int + }{ + {"community-wiki", "status=pending", 1}, + {"mcp-prod", "status=published&tier=thin", 1}, + {"mcp-prod", "tier=strong,thin", 2}, + {"mcp-prod", "", 2}, + } { + r := j.do(c.who, "GET", "/v1/articles?"+c.query, nil) + j.want(r, 200, "") + if n := len(r.body["items"].([]any)); n != c.want { + t.Errorf("%s ?%s: %d items, want %d", c.who, c.query, n, c.want) + } + } + j.want(j.do("community-wiki", "GET", "/v1/articles?status=held", nil), 400, "invalid_param") +} + +func anyStrings(v any) []string { + var out []string + for _, x := range v.([]any) { + out = append(out, x.(string)) + } + return out +} + +func equalJSON(a, b any) bool { + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return string(x) == string(y) +} diff --git a/cmd/cosift/serve_v1_joint_test.go b/cmd/cosift/serve_v1_joint_test.go index 2e7ab26..6240965 100644 --- a/cmd/cosift/serve_v1_joint_test.go +++ b/cmd/cosift/serve_v1_joint_test.go @@ -35,6 +35,7 @@ var jointOIDC = map[string]oidcID{ "synth-staging": {"300000000000000000003", "synth-staging@cosift-test.iam.gserviceaccount.com"}, "resolver-prod": {"300000000000000000004", "resolver-prod@cosift-test.iam.gserviceaccount.com"}, "resolver-staging": {"300000000000000000005", "resolver-staging@cosift-test.iam.gserviceaccount.com"}, + "intake-prod": {"300000000000000000006", "intake-prod@cosift-test.iam.gserviceaccount.com"}, } type joint struct { @@ -66,6 +67,7 @@ func jointConfig(listen string, keys map[string]string) map[string]any { oidc("synth-staging", "staging", []string{"articles:read", "articles:write", "retrieve:read"}, "writes_per_hour", 600, "writes_burst", 600, "writes_per_day", 5000), oidc("resolver-prod", "prod", []string{"articles:read", "articles:stub", "retrieve:read"}), oidc("resolver-staging", "staging", []string{"articles:read", "articles:stub", "retrieve:read"}), + oidc("intake-prod", "prod", []string{"articles:read", "articles:contribute", "retrieve:read"}, "writes_per_hour", 120, "writes_burst", 20, "writes_per_day", 700), key("community-wiki", "prod", []string{"articles:read"}), key("community-broker", "prod", []string{"community:intake", "work:broker"}), key("dash-prod", "prod", []string{"articles:read_all", "articles:moderate"}), @@ -271,7 +273,7 @@ func TestJointRouteTable(t *testing.T) { t.Errorf("%s is not mounted", rt.Pattern()) } } - if n := len(j.l.articles.Routes()); n != 11 { + if n := len(j.l.articles.Routes()); n != 12 { t.Fatalf("%d article routes", n) } j.want(j.do("synth-prod", "POST", "/v1/search", map[string]any{"q": "consensus"}), 200, "") @@ -312,7 +314,7 @@ func TestJointPerOperationScope(t *testing.T) { j.want(j.do("resolver-prod", "PUT", "/v1/articles/"+jointID(3), jointStub("Resolver stub title", "8624225e00000002")), 201, "") r = j.do("mcp-prod", "PUT", "/v1/articles/"+jointID(4), jointStub("Readers write no stubs", "8624225e00000003")) j.want(r, 403, "missing_scope") - if r.body["detail"] != "requires scope articles:write or articles:stub" { + if r.body["detail"] != "requires scope articles:write or articles:stub or articles:contribute" { t.Fatalf("detail %v", r.body["detail"]) } j.want(j.do("mcp-prod", "POST", "/v1/articles/match", map[string]any{"q": "x", "purpose": "build"}), 403, "missing_scope") @@ -744,9 +746,11 @@ func TestJointEmbedCacheAndShutdownFlush(t *testing.T) { if m.code != 200 || m.body["verdict"] != "covered" { t.Fatalf("match: %d %s", m.code, m.raw) } + j.want(j.do("intake-prod", "PUT", "/v1/articles/"+id, jointContribution(title, jointCid, "lead", "A contributed lead. It differs.", "expected_version", 1)), 200, "") + j.want(j.do("dash-prod", "POST", "/v1/articles/"+id+"/revert", map[string]any{"to_version": 1, "reason_code": "quality", "by": "operator"}), 200, "") denseReady() if n := files(); n != 0 { - t.Fatalf("the /v1 match, PUT and search left %d cache files", n) + t.Fatalf("the /v1 match, PUT, contribution, revert and search left %d cache files", n) } }) serve(func(mainAddr string) { diff --git a/internal/store/pebble.go b/internal/store/pebble.go index 8b501fd..e502ef8 100644 --- a/internal/store/pebble.go +++ b/internal/store/pebble.go @@ -15,7 +15,7 @@ // 'u' + url → uint64(docID) // URL → ID index // 'h' + host + 0x00 + uint64(id) → empty // host scan index // 'm' + "next_doc_id" → uint64(next ID) // counter -// 'R' + sub + suffix → article store // internal/articles +// 'R' + sub + suffix → article store // internal/articles; records carry origin and contributors // // Big-endian IDs give natural ascending order for prefix scans. Family-tag // prefix bytes keep families disjoint so a scan over 'd' won't touch 'u' rows. From b520b4083d0df527965ba4d0d387358b923f56a4 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 03:04:23 +0300 Subject: [PATCH 009/121] test(articles): the contribution PUT keeps or embeds the lead as the revert tests do --- internal/articles/contribute_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/internal/articles/contribute_test.go b/internal/articles/contribute_test.go index 1cdc814..c1e0f16 100644 --- a/internal/articles/contribute_test.go +++ b/internal/articles/contribute_test.go @@ -54,11 +54,13 @@ func TestContributionShapeRules(t *testing.T) { {"2 contributor too short", func() map[string]any { return contribBody(title, "0f1e2d3c") }, 422, "contributor", "pattern", false, "invalid_field"}, {"2 contributor not a string", func() map[string]any { return with(articleBody(title), "contributor", 7) }, 422, "contributor", "pattern", false, "invalid_field"}, {"3 write principal sends contributor", func() map[string]any { return contribBody(title, cid(1)) }, 422, "contributor", "field_not_allowed", true, "invalid_field"}, + {"3 write principal sends null contributor", func() map[string]any { return with(articleBody(title), "contributor", json_null{}) }, 422, "contributor", "field_not_allowed", true, "invalid_field"}, {"4 held", func() map[string]any { return contribBody(title, cid(1), "status", "held") }, 422, "status", "must_be_published", false, "invalid_field"}, {"4 status absent", func() map[string]any { return contribBody(title, cid(1), "status", nil) }, 422, "status", "must_be_published", false, "invalid_field"}, {"5 sensitive", func() map[string]any { return contribBody(title, cid(1), "sensitivity", map[string]any{"class": "medical_advice", "reason": "x"}) }, 422, "sensitivity.class", "must_be_none", false, "invalid_field"}, + {"5 sensitivity absent", func() map[string]any { return contribBody(title, cid(1), "sensitivity", nil) }, 422, "sensitivity.class", "must_be_none", false, "invalid_field"}, {"6 topic_ids", func() map[string]any { return contribBody(title, cid(1), "topic_ids", []string{topic(1)}) }, 422, "topic_ids", "field_not_allowed", false, "invalid_field"}, {"6 cluster_ids", func() map[string]any { return contribBody(title, cid(1), "cluster_ids", []string{"c013960"}) }, 422, "cluster_ids", "field_not_allowed", false, "invalid_field"}, } @@ -399,3 +401,23 @@ func TestContributionRestorePendingRecheckedInLock(t *testing.T) { t.Error("the contribution landed") } } + +func TestContributionAcceptsEmptyTopicAndClusterLists(t *testing.T) { + h := newHarness(t) + id := ulid(1) + empty := []string{} + h.contribute(id, contribBody("Rust async runtimes", cid(1), "topic_ids", empty, "cluster_ids", empty), http.StatusCreated) + h.contribute(id, contribBody("Rust async runtimes", cid(2), "topic_ids", empty, "cluster_ids", empty, "lead", leadOf("two"), "expected_version", 1), http.StatusOK) +} + +func TestContributionRefusalsKeepTheirOrderOnAFullRecord(t *testing.T) { + h := newHarness(t) + id := ulid(1) + fillContributors(h, id, 64) + fresh := cid(200) + tier := contribBody("Rust async runtimes", fresh, "quality_tier", "thin", "lead", leadOf("thin"), "expected_version", 64) + h.contribError(id, tier, 409, "tier_regression") + invalid := contribBody("Rust async runtimes", fresh, "lead", "", "expected_version", 64) + h.contribError(id, invalid, 422, "invalid_field") + h.contribError(id, contribBody("Rust async runtimes", fresh, "lead", leadOf("full"), "expected_version", 64), 409, "contributors_full") +} From b8c26760107332487d5df07c619c72eba9c4592b Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 03:32:31 +0300 Subject: [PATCH 010/121] feat(wiki): resolve contributor ids to handles through a bounded cache A banned, unknown or malformed contributor shows as a community member, and a failing source is never cached. --- internal/community/wiki/attribution.go | 76 ++++++++ internal/community/wiki/attribution_test.go | 195 ++++++++++++++++++++ internal/community/wiki/cache.go | 73 ++++++++ internal/community/wiki/wiki.go | 14 +- 4 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 internal/community/wiki/attribution.go create mode 100644 internal/community/wiki/attribution_test.go diff --git a/internal/community/wiki/attribution.go b/internal/community/wiki/attribution.go new file mode 100644 index 0000000..fbf249f --- /dev/null +++ b/internal/community/wiki/attribution.go @@ -0,0 +1,76 @@ +package wiki + +import ( + "context" + "regexp" +) + +var ( + cidPattern = regexp.MustCompile(`^[0-9a-f]{16}$`) + handlePattern = regexp.MustCompile(`^[a-z0-9_-]{3,24}$`) +) + +const maxContributors = 64 + +// contributorCIDs reads the contributors member: at most 64 16-hex strings, else none. +func contributorCIDs(raw []any) []string { + if len(raw) > maxContributors { + return nil + } + out := make([]string, 0, len(raw)) + for _, v := range raw { + s, ok := v.(string) + if !ok || !cidPattern.MatchString(s) { + return nil + } + out = append(out, s) + } + return out +} + +// displayName is "@handle" for a live contributor with a well-formed handle, else the anonymous label. +func displayName(h Handle, known bool) string { + if !known || h.Banned || !handlePattern.MatchString(h.Name) { + return anonymous + } + return "@" + h.Name +} + +// names returns one display name per cid, in order; degraded means the source failed, so the page should be served only briefly. +func (w *Wiki) names(cids []string) (out []string, degraded bool) { + now := w.now() + resolved := map[string]string{} + var missing []string + for _, cid := range cids { + if _, seen := resolved[cid]; seen { + continue + } + if name, ok := w.handles.get(cid, now); ok { + resolved[cid] = name + continue + } + resolved[cid] = anonymous + missing = append(missing, cid) + } + if len(missing) > 0 && w.cfg.Handles != nil { + ctx, cancel := context.WithTimeout(context.Background(), handleTimeout) + found, err := w.cfg.Handles.Handles(ctx, missing) + cancel() + if err != nil { + w.logOnce("handles", "wiki: WARN contributor handles unavailable; names shown as a community member") + degraded = true + } else { + fresh := map[string]string{} + for _, cid := range missing { + h, known := found[cid] + resolved[cid] = displayName(h, known) + fresh[cid] = resolved[cid] + } + w.handles.put(fresh, now) + } + } + for _, cid := range cids { + out = append(out, resolved[cid]) + } + return out, degraded +} diff --git a/internal/community/wiki/attribution_test.go b/internal/community/wiki/attribution_test.go new file mode 100644 index 0000000..3d39e2e --- /dev/null +++ b/internal/community/wiki/attribution_test.go @@ -0,0 +1,195 @@ +package wiki + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" +) + +type fakeHandles struct { + mu sync.Mutex + known map[string]Handle + err error + calls [][]string + deadline bool +} + +func (f *fakeHandles) Handles(ctx context.Context, cids []string) (map[string]Handle, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, append([]string(nil), cids...)) + _, has := ctx.Deadline() + f.deadline = has + if f.err != nil { + return nil, f.err + } + out := map[string]Handle{} + for _, c := range cids { + if h, ok := f.known[c]; ok { + out[c] = h + } + } + return out, nil +} + +func (f *fakeHandles) set(cid string, h Handle) { + f.mu.Lock() + f.known[cid] = h + f.mu.Unlock() +} + +func (f *fakeHandles) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.calls) +} + +const ( + cidA = "0f1e2d3c4b5a6978" + cidB = "5b8a1c4e9d207f36" + cidC = "1111111111111111" +) + +func withHandles(t *testing.T, f HandleSource) *harness { + return newHarness(t, func(c *Config) { c.Handles = f }) +} + +func TestNamesResolveInOrderWithOneBatchedLookup(t *testing.T) { + src := &fakeHandles{known: map[string]Handle{cidA: {Name: "tidal-fan"}, cidB: {Name: "_edge_"}}} + h := withHandles(t, src) + got, degraded := h.w.names([]string{cidB, cidA, cidB}) + if degraded || fmt.Sprint(got) != "[@_edge_ @tidal-fan @_edge_]" { + t.Fatalf("%v degraded=%v", got, degraded) + } + if len(src.calls) != 1 || fmt.Sprint(src.calls[0]) != fmt.Sprint([]string{cidB, cidA}) || !src.deadline { + t.Fatalf("lookups %v, deadline %v", src.calls, src.deadline) + } +} + +func TestBannedUnknownAndMalformedHandlesShowAsACommunityMember(t *testing.T) { + src := &fakeHandles{known: map[string]Handle{ + cidA: {Name: "tidal-fan", Banned: true}, + cidB: {Name: "Bad Name"}, + }} + h := withHandles(t, src) + got, degraded := h.w.names([]string{cidA, cidB, cidC}) + if degraded || fmt.Sprint(got) != "[a community member a community member a community member]" { + t.Fatalf("%v degraded=%v", got, degraded) + } + for _, bad := range []string{"ab", "a b", "", `">`, "a b", "x‮y", "javascript:alert(1)"} { + src := &fakeHandles{known: map[string]Handle{cidA: {Name: name}}} + h := withHandles(t, src) + h.v1.set("tidal-power", jsonReply(200, communityArticle("tidal-power", cidA))) + body := h.get("/wiki/tidal-power").Body.String() + doc := structure(t, "handle", body) + if got := textOf(contribSpan(doc)[0]); got != "· Contributed by a community member" || strings.Contains(body, "alert(1)") { + t.Fatalf("handle %q rendered as %q", name, got) + } + } + src := &fakeHandles{known: map[string]Handle{cidA: {Name: "_a_"}}} + h := withHandles(t, src) + h.v1.set("tidal-power", jsonReply(200, communityArticle("tidal-power", cidA))) + doc := structure(t, "underscore", h.get("/wiki/tidal-power").Body.String()) + if got := textOf(contribSpan(doc)[0]); got != "· Contributed by @_a_" { + t.Fatalf("%q", got) + } +} + +func TestARenameShowsWhenTheCachesExpire(t *testing.T) { + src := &fakeHandles{known: map[string]Handle{cidA: {Name: "old-name"}}} + h := withHandles(t, src) + h.v1.set("tidal-power", jsonReply(200, communityArticle("tidal-power", cidA))) + h.get("/wiki/tidal-power") + src.set(cidA, Handle{Name: "new-name"}) + h.clock.Add(articleBound - 1) + if got := h.get("/wiki/tidal-power").Body.String(); !strings.Contains(got, "@old-name") { + t.Fatal("the page changed before its cache expired") + } + h.clock.Add(2) + if got := h.get("/wiki/tidal-power").Body.String(); !strings.Contains(got, "@new-name") { + t.Fatal("the rename did not show once the caches expired") + } +} + +func TestAPageRenderedWithoutHandlesIsServedBrieflyNotForFiveMinutes(t *testing.T) { + src := &fakeHandles{known: map[string]Handle{cidA: {Name: "tidal-fan"}}, err: fmt.Errorf("unavailable")} + h := withHandles(t, src) + h.v1.set("tidal-power", jsonReply(200, communityArticle("tidal-power", cidA))) + rec := h.get("/wiki/tidal-power") + wantPage(t, rec, 200, public(int(retryEvery.Seconds()))) + if !strings.Contains(rec.Body.String(), "Contributed by a community member") { + t.Fatal("no fallback label") + } + src.mu.Lock() + src.err = nil + src.mu.Unlock() + h.clock.Add(retryEvery) + if got := h.get("/wiki/tidal-power").Body.String(); !strings.Contains(got, "@tidal-fan") { + t.Fatal("the page did not recover") + } +} + +func TestArticlePageCarriesTheLicenceLine(t *testing.T) { + h := newHarness(t) + h.v1.set("tidal-power", jsonReply(200, published("tidal-power", "Tidal power"))) + doc := structure(t, "licence", h.get("/wiki/tidal-power").Body.String()) + lic := find(doc, func(n *html.Node) bool { return n.Data == "a" && attr(n, "rel") == "license noopener" }) + if len(lic) != 1 || attr(lic[0], "href") != "https://creativecommons.org/licenses/by-sa/4.0/" || textOf(lic[0]) != "CC BY-SA 4.0" { + t.Fatalf("licence link %v", lic) + } + p := ancestor(lic[0], tag("p")) + if p == nil || !strings.Contains(textOf(p), "Creative Commons Attribution-ShareAlike 4.0 International") { + t.Fatal("no licence sentence") + } + h.v1.set("pending-one", jsonReply(200, stub("pending-one", "Pending one"))) + if strings.Contains(h.get("/wiki/pending-one").Body.String(), "CC BY-SA") { + t.Fatal("a stub carries the licence line") + } +} + +func TestVersionContributorAndRevertsToAreNeverRendered(t *testing.T) { + src := &fakeHandles{known: map[string]Handle{cidA: {Name: "tidal-fan"}}} + h := withHandles(t, src) + h.v1.set("tidal-power", jsonReply(200, with(communityArticle("tidal-power", cidA), "version_contributor", "CANARY-vc", "reverts_to", "CANARY-rt"))) + if strings.Contains(h.get("/wiki/tidal-power").Body.String(), "CANARY") { + t.Fatal("a canary was rendered") + } +} + +func checkGolden(t *testing.T, file, got string) { + t.Helper() + path := filepath.Join("testdata", "golden", file) + if *update { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run with -update)", err) + } + if string(want) != got { + t.Fatalf("%s differs from %s", file, path) + } +} + +func TestGoldenCommunityArticle(t *testing.T) { + src := &fakeHandles{known: map[string]Handle{cidA: {Name: "tidal-fan"}, cidB: {Name: "rusty", Banned: true}, cidC: {Name: "_edge_"}}} + h := withHandles(t, src) + h.v1.set("rust-async-runtimes", jsonReply(200, with(rustArticle(), "origin", "community", "contributors", []any{cidA, cidB, cidC}))) + checkGolden(t, "article-community.html", h.get("/wiki/rust-async-runtimes").Body.String()) +} + +func TestRendererEscapesAContributorNameEvenIfOneGotThrough(t *testing.T) { + h := newHarness(t) + rec := published("tidal-power", "Tidal power") + eng := engineRecord{} + body, _ := json.Marshal(rec) + if err := json.Unmarshal(body, &eng); err != nil { + t.Fatal(err) + } + page := string(h.w.renderArticle(&eng, []string{`@`, `">`})) + if strings.Contains(page, " + + +
+cosift● + +
+
+
+
+

Rust async runtimes

+

Updated · Developer docs · High quality Agent submitted · Contributed by @tidal-fan, a community member, @_edge_

+
+
+

Rust's async functions compile to state machines that do nothing until an executor polls them. Runtimes supply that executor together with I/O reactors, timers and task scheduling [1].

+

Execution model

+

An async fn in Rust returns a value that implements the Future trait. Calling it runs none of its body: the compiler turns the function into a state machine whose states are the points where it awaits, and the state machine advances only when something calls poll on it [1]. That something is an executor. A future that cannot make progress returns Poll::Pending and hands the executor a waker; when the resource it waits on becomes ready, the waker schedules the task to be polled again [2]. This design keeps futures cheap, because no thread and no stack are reserved for a task that is waiting.

+

The standard library defines Future, Poll, Context and Waker, but it ships no executor and no asynchronous input and output. A runtime supplies both. It pairs the executor with a reactor that registers sockets, files and timers with the operating system's readiness interface, such as epoll, kqueue or IOCP, and wakes the tasks whose resources became ready [1].

+

Scheduling

+

Runtimes differ mainly in how they place tasks on threads. A single-threaded executor polls every task on the thread that runs it, which avoids synchronisation and suits small services and tests. A multi-threaded executor keeps a run queue per worker thread; an idle worker steals queued tasks from a busy one, which balances load without a central lock [3]. Tasks spawned on a multi-threaded runtime must be Send, because any worker may resume them after an await point.

+

Blocking inside a task stalls every other task on that worker. Long computations and blocking system calls therefore belong on a separate blocking pool, which runtimes expose as a dedicated spawn function, or on a thread of their own [2].

+

Choosing a runtime

+

Libraries that perform input and output usually target one runtime's traits for sockets and timers, so the choice of runtime tends to follow the libraries an application uses. Code that only composes futures, with combinators or select-style macros, can stay runtime-agnostic. Cancellation is cooperative: dropping a future stops it at its last await point, so work that must finish, such as flushing a buffer, needs its own task or an explicit guard [2].

+

Key facts

+
    +
  • An async fn compiles to a state machine that advances only when an executor polls it [1].
  • +
  • A pending future registers a waker, and the waker reschedules the task when its resource is ready [2].
  • +
  • The standard library defines the Future trait but provides no executor or reactor [1].
  • +
  • Multi-threaded runtimes balance load by letting idle workers steal queued tasks [3].
  • +
  • Blocking work belongs on a separate blocking pool, never inside an async task [2].
+
+
+

Sources

+
    +
  • +

    Async runtime tutorial docs.example.org

    +
      +
    • [1]

      A runtime drives futures to completion by polling them when they can make progress.

    • +
    +
  • +
  • +

    Executors and wakers docs.example.org

    +
      +
    • [2]

      A waker tells the executor that a task is ready to be polled again.

    • +
    +
  • +
  • +

    Work-stealing schedulers blog.example.net

    +
      +
    • [3]

      Idle worker threads steal queued tasks from busy ones.

    • +
    +
  • +
+
+ +
+ +
+ + + diff --git a/internal/community/wiki/testdata/golden/article.html b/internal/community/wiki/testdata/golden/article.html index bc366a4..25a39e1 100644 --- a/internal/community/wiki/testdata/golden/article.html +++ b/internal/community/wiki/testdata/golden/article.html @@ -76,6 +76,7 @@

Sources

diff --git a/internal/community/wiki/wiki.go b/internal/community/wiki/wiki.go index d80bf93..856822e 100644 --- a/internal/community/wiki/wiki.go +++ b/internal/community/wiki/wiki.go @@ -364,7 +364,17 @@ func (w *Wiki) load(slug string, now time.Time) (*entry, int) { var e *entry switch res.kind { case outArticle: - e = &entry{kind: outArticle, status: http.StatusOK, body: w.renderArticle(res.rec), bound: articleBound} + bound := articleBound + var contributors []string + if cids := res.rec.attributed(); len(cids) > 0 { + var degraded bool + if contributors, degraded = w.names(cids); degraded { + bound = retryEvery + } else { + bound = max(0, bound-w.handles.oldest(cids, w.now())) + } + } + e = &entry{kind: outArticle, status: http.StatusOK, body: w.renderArticle(res.rec, contributors), bound: bound} case outStub: e = &entry{status: http.StatusOK, body: w.renderStub(res.rec), bound: articleBound} case outRedirect: From 85fe90d2b3df5044d8ff28b549efcac05aa84b82 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 03:42:25 +0300 Subject: [PATCH 013/121] feat(wiki): serve /wiki/.md, the article as safe Markdown Free like the page and cached with it. Raw HTML, inline links and reference definitions become text, handles are escaped, and the licence line closes the file. --- internal/community/wiki/cache.go | 2 + internal/community/wiki/md.go | 195 +++++++++ internal/community/wiki/md_cmark_test.go | 176 ++++++++ internal/community/wiki/md_test.go | 377 ++++++++++++++++++ .../wiki/testdata/golden/article-community.md | 42 ++ .../community/wiki/testdata/golden/article.md | 42 ++ .../community/wiki/testdata/injection.json | 19 +- internal/community/wiki/wiki.go | 53 ++- scripts/md-cmark-check.sh | 10 + 9 files changed, 896 insertions(+), 20 deletions(-) create mode 100644 internal/community/wiki/md.go create mode 100644 internal/community/wiki/md_cmark_test.go create mode 100644 internal/community/wiki/md_test.go create mode 100644 internal/community/wiki/testdata/golden/article-community.md create mode 100644 internal/community/wiki/testdata/golden/article.md create mode 100755 scripts/md-cmark-check.sh diff --git a/internal/community/wiki/cache.go b/internal/community/wiki/cache.go index 17360a8..dff7ba8 100644 --- a/internal/community/wiki/cache.go +++ b/internal/community/wiki/cache.go @@ -36,6 +36,8 @@ type entry struct { kind outcome status int body []byte + md []byte + target string location string fetched time.Time bound time.Duration diff --git a/internal/community/wiki/md.go b/internal/community/wiki/md.go new file mode 100644 index 0000000..7bfdcd5 --- /dev/null +++ b/internal/community/wiki/md.go @@ -0,0 +1,195 @@ +package wiki + +import ( + "net/http" + "strconv" + "strings" +) + +const ( + mdType = "text/markdown; charset=utf-8" + textType = "text/plain; charset=utf-8" + + licenceURL = "https://creativecommons.org/licenses/by-sa/4.0/" + licenceLine = "This article's text is available under the [Creative Commons Attribution-ShareAlike 4.0 International licence (CC BY-SA 4.0)](" + licenceURL + ")." +) + +// escapeInline backslash-escapes the characters that would start markup in a single line of text. +func escapeInline(s string) string { + var b strings.Builder + for _, r := range s { + if strings.ContainsRune("\\`*_[]<>|~", r) { + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + +// mdLine neutralises one body line outside a fence: raw HTML, inline links and the colon of a reference definition become text; escapes are kept, and so are code spans unless a backtick run of an earlier line of the paragraph is still open (loose). It reports whether a run stayed open. +func mdLine(line string, loose bool) (string, bool) { + var b strings.Builder + for i := 0; i < len(line); { + c := line[i] + switch { + case c == '\\' && i+1 < len(line) && isPunct(line[i+1]): + b.WriteString(line[i : i+2]) + i += 2 + case c == '`': + n := run(line, i, '`') + end := -1 + if !loose { + end = closingTicks(line, i+n, n) + } + switch { + case end >= 0 && !strings.Contains(line[i+n:end], "|"): + b.WriteString(strings.ReplaceAll(line[i:end+n], "]:", `]\:`)) + i = end + n + default: + if end < 0 { + loose = true + } + b.WriteString(line[i : i+n]) + i += n + } + case c == '<': + b.WriteString(`\<`) + i++ + case c == ']' && i+1 < len(line) && line[i+1] == '(': + b.WriteString(`]\(`) + i += 2 + case c == ']' && i+1 < len(line) && line[i+1] == ':': + b.WriteString(`]\:`) + i += 2 + default: + b.WriteByte(c) + i++ + } + } + return b.String(), loose +} + +// setextRule is a line of only = or -, which would turn the line above into a heading. +func setextRule(line string) bool { + n, rest := indentOf(line) + rest = strings.TrimRight(rest, " \t") + return n <= 3 && rest != "" && (strings.Trim(rest, "=") == "" || strings.Trim(rest, "-") == "") +} + +// mdBody returns the article text as Markdown that is safe to hand to a renderer: fences are re-emitted at column 0 with their content as written and closed at the end, everything else goes through mdLine, and headings are normalised as on the page. +func mdBody(text string) string { + var out []string + open, indent := "", 0 + sawH2, loose := false, false + for _, line := range strings.Split(clean(text), "\n") { + switch { + case open != "": + if closesFence(line, open) { + out = append(out, open) + open = "" + continue + } + out = append(out, trimIndent(line, indent)) + case fence(line) != "": + open = fence(line) + indent, loose = leadingSpaces(line), false + _, rest := indentOf(line) + out = append(out, rest) + case setextRule(line): + n, rest := indentOf(line) + out = append(out, strings.Repeat(" ", n)+`\`+rest) + case blank(line): + loose = false + out = append(out, line) + default: + if level, t := heading(line); level > 0 { + if t == "" { + continue + } + mark := "###" + if level <= 2 || !sawH2 { + mark, sawH2 = "##", true + } + text, _ := mdLine(t, loose) + out = append(out, mark+" "+text) + continue + } + var text string + text, loose = mdLine(line, loose) + out = append(out, text) + } + } + if open != "" { + out = append(out, open) + } + return strings.Join(out, "\n") +} + +// leadingSpaces counts the spaces before the first non-space character. +func leadingSpaces(line string) int { + return len(line) - len(strings.TrimLeft(line, " ")) +} + +// trimIndent drops up to n leading spaces, as a renderer does for the content of an indented fence. +func trimIndent(line string, n int) string { + k := min(n, leadingSpaces(line)) + return line[k:] +} + +// renderMarkdown is the article as a .md page: title, status line, text, sources and the licence line. +func (w *Wiki) renderMarkdown(rec *engineRecord, contributors []string) []byte { + var b strings.Builder + b.WriteString("# " + escapeInline(cleanLine(rec.Title)) + "\n\nAgent submitted") + if len(contributors) > 0 { + var names []string + for _, c := range contributors { + names = append(names, escapeInline(c)) + } + b.WriteString(" · Contributed by " + strings.Join(names, ", ")) + } + b.WriteString("\n\n" + strings.TrimRight(mdBody(rec.Lead+"\n\n"+rec.BodyMD), "\n") + "\n") + if len(rec.Citations) > 0 { + b.WriteString("\n## Sources\n\n") + } + seen := map[string]bool{} + for _, c := range rec.Citations { + if seen[c.URL] { + continue + } + seen[c.URL] = true + title, host := cleanLine(c.Title), cleanLine(c.Host) + if title == "" { + title = host + } + b.WriteString("- " + escapeInline(title) + " (" + escapeInline(host) + ")") + if linkable(c.URL) && !strings.ContainsAny(c.URL, "<>") { + b.WriteString(" <" + c.URL + ">") + } + b.WriteString("\n") + for _, d := range rec.Citations { + if d.URL == c.URL { + b.WriteString(" - [" + strconv.Itoa(d.N) + "] " + escapeInline(cleanQuote(d.Quote)) + "\n") + } + } + } + b.WriteString("\n---\n\n" + licenceLine + " Canonical page: <" + w.canonical("/wiki/"+rec.Slug) + ">\n") + return []byte(b.String()) +} + +// refuse answers with a short plain-text body for a Markdown request, or the page for an HTML one. +func (w *Wiki) refuse(rw http.ResponseWriter, status int, md bool) { + if md { + w.write(rw, status, textType, []byte(http.StatusText(status)+"\n"), -1) + return + } + body := w.notFound + switch status { + case http.StatusServiceUnavailable: + body = w.unavailable + case http.StatusTooManyRequests: + body = w.tooMany + case http.StatusGone: + body = w.gone + } + w.write(rw, status, htmlType, body, -1) +} diff --git a/internal/community/wiki/md_cmark_test.go b/internal/community/wiki/md_cmark_test.go new file mode 100644 index 0000000..b4365b7 --- /dev/null +++ b/internal/community/wiki/md_cmark_test.go @@ -0,0 +1,176 @@ +//go:build cmark + +package wiki + +import ( + "bytes" + "fmt" + "io" + "os/exec" + "strings" + "testing" + "time" + + "golang.org/x/net/html" +) + +var cmarkArticleTags = map[string]bool{"h1": true, "h2": true, "h3": true, "p": true, "ul": true, "li": true, "a": true} + +var cmarkLlmsTags = map[string]bool{"h1": true, "h2": true, "p": true, "blockquote": true, "ul": true, "li": true, "a": true} + +type cmarkFlavour struct { + bin string + args []string +} + +var cmarkFlavours = []cmarkFlavour{ + {"cmark", []string{"--unsafe"}}, + {"cmark-gfm", []string{"--unsafe", "-e", "table", "-e", "strikethrough", "-e", "autolink", "-e", "tasklist"}}, +} + +func cmarkRender(t *testing.T, f cmarkFlavour, md string) string { + t.Helper() + cmd := exec.Command(f.bin, f.args...) + cmd.Stdin = strings.NewReader(md) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + if err := cmd.Run(); err != nil { + t.Fatalf("%s: %v: %s", f.bin, err, errb.String()) + } + return out.String() +} + +// cmarkInspect fails unless the rendered HTML holds only the allowed tags, links only to the allowed URLs and, once whitespace is folded, every visible string. +func cmarkInspect(t *testing.T, name, rendered string, tags map[string]bool, urls map[string]bool, visible []string) { + t.Helper() + z := html.NewTokenizer(strings.NewReader(rendered)) + var text strings.Builder + h1 := 0 + for { + tt := z.Next() + if tt == html.ErrorToken { + if z.Err() != io.EOF { + t.Fatalf("%s: %v", name, z.Err()) + } + break + } + tok := z.Token() + switch tt { + case html.TextToken: + text.WriteString(tok.Data) + case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken: + if !tags[tok.Data] || tt == html.SelfClosingTagToken { + t.Fatalf("%s: element %v in\n%s", name, tok, rendered) + } + if tt == html.EndTagToken { + continue + } + if tok.Data == "h1" { + h1++ + } + if tok.Data != "a" { + if len(tok.Attr) != 0 { + t.Fatalf("%s: attributes on %v", name, tok) + } + continue + } + if len(tok.Attr) != 1 || tok.Attr[0].Key != "href" || !urls[tok.Attr[0].Val] { + t.Fatalf("%s: link %v", name, tok) + } + default: + t.Fatalf("%s: %v token %q in\n%s", name, tt, tok.String(), rendered) + } + } + if tags["h1"] && h1 != 1 { + t.Fatalf("%s: %d h1 elements", name, h1) + } + plain := strings.Join(strings.Fields(text.String()), " ") + for _, v := range visible { + if !strings.Contains(plain, strings.Join(strings.Fields(v), " ")) { + t.Fatalf("%s: lost visible text %q in\n%s", name, v, plain) + } + } +} + +func urlSet(urls ...string) map[string]bool { + m := map[string]bool{} + for _, u := range urls { + m[u] = true + } + return m +} + +func TestCmarkRendersTheInjectionCorpusAsPlainText(t *testing.T) { + h := newHarness(t) + h.refresh() + cites := func() []any { + return []any{ + map[string]any{"n": 1, "url": corpusCites[0], "title": "One", "host": "docs.example.org", "quote": "q"}, + map[string]any{"n": 2, "url": corpusCites[1], "title": "Two", "host": "docs.example.org", "quote": "q"}, + } + } + allowed := urlSet(corpusCites[0], licenceURL) + for _, f := range cmarkFlavours { + for i, tc := range loadCorpus(t) { + for _, field := range []string{"body_md", "title", "citation"} { + t.Run(f.bin+"/"+tc.Name+"/"+field, func(t *testing.T) { + slug := fmt.Sprintf("case-%d-%s", i, strings.ReplaceAll(field, "_", "-")) + rec := with(published(slug, "Case"), "citations", cites()) + visible := tc.Visible + switch field { + case "body_md": + rec = with(rec, "body_md", tc.Text) + case "title": + rec = with(rec, "title", tc.Text) + visible = nil + case "citation": + rec = with(rec, "citations", []any{map[string]any{"n": 1, "url": corpusCites[0], "title": tc.Text, "host": tc.Text, "quote": tc.Text}}) + visible = nil + } + h.v1.set(slug, jsonReply(200, rec)) + h.clock.Add(time.Second) + got := h.get("/wiki/"+slug+".md", "RemoteAddr", fmt.Sprintf("192.0.2.%d:1", i%200+1)) + if got.Code != 200 { + t.Fatalf("%d", got.Code) + } + urls := urlSet("https://cosift.example/wiki/" + slug) + for u := range allowed { + urls[u] = true + } + cmarkInspect(t, tc.Name, cmarkRender(t, f, got.Body.String()), cmarkArticleTags, urls, visible) + }) + } + } + } +} + +func TestCmarkRendersTheGoldenPagesAsPlainText(t *testing.T) { + src := &fakeHandles{known: map[string]Handle{cidA: {Name: "tidal-fan"}, cidB: {Name: "rusty", Banned: true}, cidC: {Name: "_edge_"}}} + h := withHandles(t, src) + h.v1.set("rust-async-runtimes", jsonReply(200, with(rustArticle(), "origin", "community", "contributors", []any{cidA, cidB, cidC}))) + md := h.get("/wiki/rust-async-runtimes.md").Body.String() + urls := urlSet("https://docs.example.org/async/runtime-tutorial", "https://docs.example.org/async/executors", "https://blog.example.net/work-stealing", + licenceURL, "https://cosift.example/wiki/rust-async-runtimes") + for _, f := range cmarkFlavours { + cmarkInspect(t, f.bin+"/golden", cmarkRender(t, f, md), cmarkArticleTags, urls, + []string{"Rust async runtimes", "Agent submitted · Contributed by @tidal-fan, a community member, @_edge_", "An `async fn` in Rust returns", "[1] A runtime drives futures to completion"}) + } +} + +func TestCmarkRendersLlmsTxtAsPlainText(t *testing.T) { + h := newHarness(t) + var items []map[string]any + for i, tc := range loadCorpus(t) { + items = append(items, item(published(fmt.Sprintf("case-%03d", i), tc.Text))) + } + h.v1.list(items...) + h.refresh() + out := h.get("/llms.txt").Body.String() + urls := urlSet(licenceURL, "https://cosift.example/wiki/style.md", "https://cosift.example/wiki/terms", "https://cosift.example/wiki/wanted") + for i := range items { + urls[fmt.Sprintf("https://cosift.example/wiki/case-%03d.md", i)] = true + } + for _, f := range cmarkFlavours { + cmarkInspect(t, f.bin+"/llms.txt", cmarkRender(t, f, out), cmarkLlmsTags, urls, nil) + } +} diff --git a/internal/community/wiki/md_test.go b/internal/community/wiki/md_test.go new file mode 100644 index 0000000..006bfa9 --- /dev/null +++ b/internal/community/wiki/md_test.go @@ -0,0 +1,377 @@ +package wiki + +import ( + "fmt" + "net/http" + "regexp" + "strings" + "testing" + "time" +) + +var ( + mdEscape = regexp.MustCompile(`\\.`) + autolink = regexp.MustCompile(`\s]+>`) + mdSetext = regexp.MustCompile(`^ {0,3}(=+|-+) *$`) + mdFenceLn = regexp.MustCompile("^ {0,3}(```|~~~)") + mdBidi = regexp.MustCompile("[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]") +) + +// mdInert fails when a Markdown renderer could find markup that is not plain text. +func mdInert(t *testing.T, name, out string) { + t.Helper() + if mdBidi.MatchString(out) { + t.Fatalf("%s: control or bidi character in the output", name) + } + fenced, loose := false, false + ours := len(out) + if k := strings.LastIndex(out, "\n## Sources\n"); k >= 0 { + ours = k + } else if k := strings.LastIndex(out, "\n---\n"); k >= 0 { + ours = k + } + offset := 0 + for i, line := range strings.Split(out, "\n") { + generated := offset >= ours + offset += len(line) + 1 + if mdFenceLn.MatchString(line) { + if line[0] != '`' && line[0] != '~' { + t.Fatalf("%s: indented fence on line %d: %q", name, i, line) + } + fenced, loose = !fenced, false + continue + } + if fenced { + continue + } + if strings.TrimSpace(line) == "" { + loose = false + continue + } + for k := strings.Index(line, "]:"); k >= 0; { + if k == 0 || line[k-1] != '\\' { + t.Fatalf("%s: reference definition on line %d: %q", name, i, line) + } + next := strings.Index(line[k+2:], "]:") + if next < 0 { + break + } + k += 2 + next + } + plain := mdEscape.ReplaceAllString(line, "") + plain, lone := stripSpans(plain, loose) + loose = loose || lone + if generated { + plain = autolink.ReplaceAllString(plain, "") + } + switch { + case strings.Contains(plain, "<"): + t.Fatalf("%s: raw < on line %d: %q", name, i, line) + case strings.Contains(plain, "](") && !(generated && strings.Contains(line, licenceURL)): + t.Fatalf("%s: inline link on line %d: %q", name, i, line) + case mdSetext.MatchString(line) && !(generated && line == "---"): + t.Fatalf("%s: setext rule on line %d: %q", name, i, line) + case i > 0 && strings.HasPrefix(line, "# "): + t.Fatalf("%s: second h1 on line %d: %q", name, i, line) + } + } +} + +// stripSpans removes inline code that opens and closes on this line, unless an earlier line of the paragraph left a backtick run open; it reports whether a run stayed unmatched. +func stripSpans(s string, loose bool) (string, bool) { + var b strings.Builder + lone := false + for i := 0; i < len(s); { + if s[i] != '`' { + b.WriteByte(s[i]) + i++ + continue + } + n := 0 + for i+n < len(s) && s[i+n] == '`' { + n++ + } + ticks := strings.Repeat("`", n) + end := strings.Index(s[i+n:], ticks) + for end >= 0 && i+n+end+n < len(s) && s[i+n+end+n] == '`' { + rest := s[i+n+end:] + skip := len(rest) - len(strings.TrimLeft(rest, "`")) + next := strings.Index(s[i+n+end+skip:], ticks) + if next < 0 { + end = -1 + break + } + end += skip + next + } + switch { + case end < 0: + lone = true + b.WriteString(ticks) + i += n + case loose || strings.Contains(s[i+n:i+n+end], "|"): + b.WriteString(ticks) + i += n + default: + i += n + end + n + } + } + return b.String(), lone +} + +func unescapeMD(s string) string { + return mdEscape.ReplaceAllStringFunc(s, func(m string) string { + if isPunct(m[1]) { + return m[1:] + } + return m + }) +} + +func TestInjectionCorpusInsideTheMarkdownForm(t *testing.T) { + h := newHarness(t) + h.refresh() + for i, tc := range loadCorpus(t) { + slug := fmt.Sprintf("case-%d", i) + cites := []any{ + map[string]any{"n": 1, "url": corpusCites[0], "title": "One", "host": "docs.example.org", "quote": "q"}, + map[string]any{"n": 2, "url": corpusCites[1], "title": "Two", "host": "docs.example.org", "quote": "q"}, + } + t.Run(tc.Name, func(t *testing.T) { + h.v1.set(slug, jsonReply(200, with(published(slug, "Case"), "body_md", tc.Text, "citations", cites))) + h.clock.Add(time.Second) + rec := h.get("/wiki/"+slug+".md", "RemoteAddr", fmt.Sprintf("192.0.2.%d:1", i%200+1)) + if rec.Code != 200 { + t.Fatalf("%d", rec.Code) + } + out := rec.Body.String() + mdInert(t, tc.Name, out) + plain := unescapeMD(out) + for _, v := range tc.Visible { + if !strings.Contains(plain, v) { + t.Fatalf("lost visible text %q", v) + } + } + }) + } +} + +func TestHandlesInTheMarkdownFormAreEscaped(t *testing.T) { + src := &fakeHandles{known: map[string]Handle{cidA: {Name: "_a_"}, cidB: {Name: "x_y-z"}, cidC: {Name: "boo"}}} + h := withHandles(t, src) + h.v1.set("tidal-power", jsonReply(200, communityArticle("tidal-power", cidA, cidB, cidC))) + out := h.get("/wiki/tidal-power.md").Body.String() + want := `Agent submitted · Contributed by @\_a\_, @x\_y-z, a community member` + if !strings.Contains(out, "\n"+want+"\n") { + t.Fatalf("attribution line missing in\n%s", out) + } + mdInert(t, "handles", out) +} + +func TestMarkdownFormCarriesTheLicenceAndNoLinksButTheCitedOnes(t *testing.T) { + h := newHarness(t) + h.v1.set("tidal-power", jsonReply(200, published("tidal-power", "Tidal power"))) + out := h.get("/wiki/tidal-power.md").Body.String() + if !strings.Contains(out, "Creative Commons Attribution-ShareAlike 4.0 International licence (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/)") { + t.Fatal("no licence line") + } + for _, want := range []string{"# Tidal power\n\nAgent submitted\n\n", "## Sources\n", "- Guide (docs.example.org) \n - [1] A verbatim quote.\n", "Canonical page: "} { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in\n%s", want, out) + } + } + mdInert(t, "plain", out) +} + +func TestMarkdownSourcesOmitAnUnsafeURLAndKeepGrouping(t *testing.T) { + h := newHarness(t) + cite := func(n int, url, quote string) map[string]any { + return map[string]any{"n": n, "url": url, "title": "T*" + fmt.Sprint(n), "host": "a.example", "quote": quote} + } + rec := with(published("grouped", "Grouped"), "citations", []any{ + cite(1, "https://a.example/page", "First quote."), cite(2, "https://a.example/", "Second."), cite(3, "https://a.example/page", "Third."), + cite(4, "javascript:alert(1)", "Fourth."), + }) + h.v1.set("grouped", jsonReply(200, rec)) + out := h.get("/wiki/grouped.md").Body.String() + for _, want := range []string{ + "- T\\*1 (a.example) \n - [1] First \\quote\\.\n - [3] Third.\n", + "- T\\*2 (a.example)\n - [2] Second.\n", + "- T\\*4 (a.example)\n - [4] Fourth.\n", + } { + if !strings.Contains(out, want) { + t.Fatalf("missing %q in\n%s", want, out) + } + } + if strings.Contains(out, "javascript:") || strings.Contains(out, "") { + t.Fatal("an unsafe URL was printed") + } +} + +func TestMarkdownRouteServesTheSameEntryAsThePage(t *testing.T) { + h := newHarness(t) + h.v1.set("tidal-power", jsonReply(200, published("tidal-power", "Tidal power"))) + page := h.get("/wiki/tidal-power") + md := h.get("/wiki/tidal-power.md") + wantPage(t, page, 200, public(300)) + wantPage(t, md, 200, public(300)) + if md.Header().Get("Content-Type") != "text/markdown; charset=utf-8" || md.Header().Get("Set-Cookie") != "" { + t.Fatalf("headers %v", md.Header()) + } + if h.v1.slugCalls("tidal-power") != 1 { + t.Fatalf("%d engine calls for one article", h.v1.slugCalls("tidal-power")) + } + h.clock.Add(100 * time.Second) + wantPage(t, h.get("/wiki/tidal-power.md"), 200, public(200)) +} + +func TestMarkdownRouteStatuses(t *testing.T) { + h := newHarness(t) + h.v1.set("pending-one", jsonReply(200, stub("pending-one", "Pending one"))) + h.v1.set("removed-topic", errReply(410, "gone")) + h.v1.set("renamed-topic", jsonReply(200, map[string]any{"redirect_slug": "tidal-power"})) + h.v1.set("broken", errReply(500, "internal")) + for _, tc := range []struct { + path string + status int + cache string + }{ + {"/wiki/pending-one.md", 404, "no-store"}, + {"/wiki/removed-topic.md", 410, "no-store"}, + {"/wiki/renamed-topic.md", 301, public(300)}, + {"/wiki/broken.md", 503, "no-store"}, + {"/wiki/nothing-here.md", 404, "no-store"}, + } { + h.clock.Add(time.Second) + rec := h.get(tc.path) + wantPage(t, rec, tc.status, tc.cache) + if tc.status != 301 && !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") { + t.Fatalf("%s: %q", tc.path, rec.Header().Get("Content-Type")) + } + } + if loc := h.get("/wiki/renamed-topic.md").Header().Get("Location"); loc != "https://cosift.example/wiki/tidal-power.md" { + t.Fatalf("location %q", loc) + } +} + +func TestMarkdownRouteIsRateLimitedPerClientAsPlainText(t *testing.T) { + h := newHarness(t) + var rec *struct{ code int } + _ = rec + for i := 0; i <= ipLimit; i++ { + got := h.get(fmt.Sprintf("/wiki/missing-%d.md", i), "RemoteAddr", "192.0.2.77:1") + if got.Code == http.StatusTooManyRequests { + if !strings.HasPrefix(got.Header().Get("Content-Type"), "text/plain") || got.Header().Get("Retry-After") == "" { + t.Fatalf("429 headers %v", got.Header()) + } + return + } + h.clock.Add(10 * time.Millisecond) + } + t.Fatal("no 429") +} + +func TestMarkdownPathsThatAreNotArticles(t *testing.T) { + h := newHarness(t) + for _, p := range []string{"/wiki/.md", "/wiki/UPPER.md", "/wiki/a.md.md", "/wiki/v.md", "/wiki/index.md", "/wiki/a/b.md"} { + rec := h.get(p) + if rec.Code != 404 || h.v1.count() != 0 { + t.Fatalf("%s: %d with %d engine calls", p, rec.Code, h.v1.count()) + } + } +} + +func TestGoldenMarkdown(t *testing.T) { + src := &fakeHandles{known: map[string]Handle{cidA: {Name: "tidal-fan"}, cidB: {Name: "rusty", Banned: true}, cidC: {Name: "_edge_"}}} + h := withHandles(t, src) + h.v1.set("rust-async-runtimes", jsonReply(200, with(rustArticle(), "origin", "community", "contributors", []any{cidA, cidB, cidC}))) + checkGolden(t, "article-community.md", h.get("/wiki/rust-async-runtimes.md").Body.String()) + plain := newHarness(t) + plain.v1.set("rust-async-runtimes", jsonReply(200, rustArticle())) + checkGolden(t, "article.md", plain.get("/wiki/rust-async-runtimes.md").Body.String()) +} + +func TestEscapeInline(t *testing.T) { + for in, want := range map[string]string{"plain": "plain", "_a_": `\_a\_`, "a*b": `a\*b`, "[x](y)": `\[x\](y)`, "": `\`, "a\\b": `a\\b`, "`c`": "\\`c\\`", "x|y~z": `x\|y\~z`} { + if got := escapeInline(in); got != want { + t.Errorf("%q -> %q, want %q", in, got, want) + } + } +} + +func TestMarkdownBodyNormalisesHeadingsLikeThePage(t *testing.T) { + in := "### Early\n\n# Top\n\n###### Deep\n\n## Real\n\n### Sub\n\n#\n\nText\n=====\n\n---\n" + want := "## Early\n\n## Top\n\n### Deep\n\n## Real\n\n### Sub\n\n\nText\n\\=====\n\n\\---\n" + if got := mdBody(in); got != want { + t.Fatalf("got\n%q\nwant\n%q", got, want) + } +} + +func TestMarkdownBodyKeepsFencesAndCodeSpans(t *testing.T) { + in := "Use `Vec` and ``a = ;\n[a](b)\n```\n\nAfter fence [l](http://x)\n[1]: http://x\n" + want := "Use `Vec` and ``a = ;\n[a](b)\n```\n\nAfter \\fence\\ [l]\\(http://x)\n[1]\\: http://x\n" + if got := mdBody(in); got != want { + t.Fatalf("got\n%q\nwant\n%q", got, want) + } +} + +func TestMarkdownBodyClosesAnOpenFence(t *testing.T) { + if got, want := mdBody("Text [1].\n\n```\nopen"), "Text [1].\n\n```\nopen\n```"; got != want { + t.Fatalf("got %q want %q", got, want) + } + h := newHarness(t) + h.v1.set("open", jsonReply(200, with(published("open", "Open"), "body_md", "Text [1].\n\n~~~\nopen", "citations", []any{ + map[string]any{"n": 1, "url": "https://a.example/p", "title": "T", "host": "a.example", "quote": "q"}, + }))) + out := h.get("/wiki/open.md").Body.String() + if !strings.Contains(out, "open\n~~~\n\n## Sources\n") { + t.Fatalf("the fence runs into the sources:\n%s", out) + } +} + +func TestMarkdownBodyMovesFencesToColumnZero(t *testing.T) { + in := "- item\n ```go\n a\n ~~~\nx\n ~~~\n" + want := "- item\n```go\na\n~~~\nx\n~~~\n" + if got := mdBody(in); got != want { + t.Fatalf("got\n%q\nwant\n%q", got, want) + } +} + +func TestMarkdownBodyKeepsASpanOnlyWhenNoRunIsOpen(t *testing.T) { + for in, want := range map[string]string{ + "x `a\nb` `c`": "x `a\nb` \\ `c`", + "x `a\nb\nc` `d`": "x `a\nb\nc` \\ `d\\`", + "x `a\n\nb `c`": "x `a\n\nb `c`", + "`ok<` x `a\nb` ": "`ok<` x `a\nb` \\", + "| `x | `": "| `x | \\`", + "[x`]: u`": "[x`]\\: u`", + "```\na\n```\n`b\n`c`": "```\na\n```\n`b\n`c\\`", + "`b\n```\na\n```\n`c`": "`b\n```\na\n```\n`c`", + "`b\n- `c` ": "`b\n- `c` \\", + "a `b` `c\nd` `e` ": "a `b` `c\nd` `e` \\", + "\\`a` ": "\\`a` \\", + "x `a\n# h `b`\n`d`": "x `a\n## h `b\\`\n`d\\`", + } { + got := mdBody(in) + if got != want { + t.Errorf("%q -> %q, want %q", in, got, want) + } + } +} + +func TestMarkdownBodyEscapesAnIndentedSetextRule(t *testing.T) { + for in, want := range map[string]string{"T\n ===": "T\n \\===", "T\n ---": "T\n \\---", "T\n ===": "T\n ==="} { + if got := mdBody(in); got != want { + t.Errorf("%q -> %q, want %q", in, got, want) + } + } +} + +func TestMarkdownFormEscapesTheCitationHost(t *testing.T) { + h := newHarness(t) + h.v1.set("host", jsonReply(200, with(published("host", "Host"), "citations", []any{ + map[string]any{"n": 1, "url": "https://a_b.example/p", "title": "T", "host": "a_b*c.example", "quote": "q"}, + }))) + if out := h.get("/wiki/host.md").Body.String(); !strings.Contains(out, "- T (a\\_b\\*c.example) \n") { + t.Fatalf("host not escaped:\n%s", out) + } +} diff --git a/internal/community/wiki/testdata/golden/article-community.md b/internal/community/wiki/testdata/golden/article-community.md new file mode 100644 index 0000000..86946fb --- /dev/null +++ b/internal/community/wiki/testdata/golden/article-community.md @@ -0,0 +1,42 @@ +# Rust async runtimes + +Agent submitted · Contributed by @tidal-fan, a community member, @\_edge\_ + +Rust's async functions compile to state machines that do nothing until an executor polls them. Runtimes supply that executor together with I/O reactors, timers and task scheduling [1]. + +## Execution model + +An `async fn` in Rust returns a value that implements the `Future` trait. Calling it runs none of its body: the compiler turns the function into a state machine whose states are the points where it awaits, and the state machine advances only when something calls `poll` on it [1]. That something is an executor. A future that cannot make progress returns `Poll::Pending` and hands the executor a waker; when the resource it waits on becomes ready, the waker schedules the task to be polled again [2]. This design keeps futures cheap, because no thread and no stack are reserved for a task that is waiting. + +The standard library defines `Future`, `Poll`, `Context` and `Waker`, but it ships no executor and no asynchronous input and output. A runtime supplies both. It pairs the executor with a reactor that registers sockets, files and timers with the operating system's readiness interface, such as epoll, kqueue or IOCP, and wakes the tasks whose resources became ready [1]. + +## Scheduling + +Runtimes differ mainly in how they place tasks on threads. A single-threaded executor polls every task on the thread that runs it, which avoids synchronisation and suits small services and tests. A multi-threaded executor keeps a run queue per worker thread; an idle worker steals queued tasks from a busy one, which balances load without a central lock [3]. Tasks spawned on a multi-threaded runtime must be `Send`, because any worker may resume them after an await point. + +Blocking inside a task stalls every other task on that worker. Long computations and blocking system calls therefore belong on a separate blocking pool, which runtimes expose as a dedicated spawn function, or on a thread of their own [2]. + +## Choosing a runtime + +Libraries that perform input and output usually target one runtime's traits for sockets and timers, so the choice of runtime tends to follow the libraries an application uses. Code that only composes futures, with combinators or `select`-style macros, can stay runtime-agnostic. Cancellation is cooperative: dropping a future stops it at its last await point, so work that must finish, such as flushing a buffer, needs its own task or an explicit guard [2]. + +## Key facts + +- An `async fn` compiles to a state machine that advances only when an executor polls it [1]. +- A pending future registers a waker, and the waker reschedules the task when its resource is ready [2]. +- The standard library defines the `Future` trait but provides no executor or reactor [1]. +- Multi-threaded runtimes balance load by letting idle workers steal queued tasks [3]. +- Blocking work belongs on a separate blocking pool, never inside an async task [2]. + +## Sources + +- Async runtime tutorial (docs.example.org) + - [1] A runtime drives futures to completion by polling them when they can make progress. +- Executors and wakers (docs.example.org) + - [2] A waker tells the executor that a task is ready to be polled again. +- Work-stealing schedulers (blog.example.net) + - [3] Idle worker threads steal queued tasks from busy ones. + +--- + +This article's text is available under the [Creative Commons Attribution-ShareAlike 4.0 International licence (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/). Canonical page: diff --git a/internal/community/wiki/testdata/golden/article.md b/internal/community/wiki/testdata/golden/article.md new file mode 100644 index 0000000..e311f8d --- /dev/null +++ b/internal/community/wiki/testdata/golden/article.md @@ -0,0 +1,42 @@ +# Rust async runtimes + +Agent submitted + +Rust's async functions compile to state machines that do nothing until an executor polls them. Runtimes supply that executor together with I/O reactors, timers and task scheduling [1]. + +## Execution model + +An `async fn` in Rust returns a value that implements the `Future` trait. Calling it runs none of its body: the compiler turns the function into a state machine whose states are the points where it awaits, and the state machine advances only when something calls `poll` on it [1]. That something is an executor. A future that cannot make progress returns `Poll::Pending` and hands the executor a waker; when the resource it waits on becomes ready, the waker schedules the task to be polled again [2]. This design keeps futures cheap, because no thread and no stack are reserved for a task that is waiting. + +The standard library defines `Future`, `Poll`, `Context` and `Waker`, but it ships no executor and no asynchronous input and output. A runtime supplies both. It pairs the executor with a reactor that registers sockets, files and timers with the operating system's readiness interface, such as epoll, kqueue or IOCP, and wakes the tasks whose resources became ready [1]. + +## Scheduling + +Runtimes differ mainly in how they place tasks on threads. A single-threaded executor polls every task on the thread that runs it, which avoids synchronisation and suits small services and tests. A multi-threaded executor keeps a run queue per worker thread; an idle worker steals queued tasks from a busy one, which balances load without a central lock [3]. Tasks spawned on a multi-threaded runtime must be `Send`, because any worker may resume them after an await point. + +Blocking inside a task stalls every other task on that worker. Long computations and blocking system calls therefore belong on a separate blocking pool, which runtimes expose as a dedicated spawn function, or on a thread of their own [2]. + +## Choosing a runtime + +Libraries that perform input and output usually target one runtime's traits for sockets and timers, so the choice of runtime tends to follow the libraries an application uses. Code that only composes futures, with combinators or `select`-style macros, can stay runtime-agnostic. Cancellation is cooperative: dropping a future stops it at its last await point, so work that must finish, such as flushing a buffer, needs its own task or an explicit guard [2]. + +## Key facts + +- An `async fn` compiles to a state machine that advances only when an executor polls it [1]. +- A pending future registers a waker, and the waker reschedules the task when its resource is ready [2]. +- The standard library defines the `Future` trait but provides no executor or reactor [1]. +- Multi-threaded runtimes balance load by letting idle workers steal queued tasks [3]. +- Blocking work belongs on a separate blocking pool, never inside an async task [2]. + +## Sources + +- Async runtime tutorial (docs.example.org) + - [1] A runtime drives futures to completion by polling them when they can make progress. +- Executors and wakers (docs.example.org) + - [2] A waker tells the executor that a task is ready to be polled again. +- Work-stealing schedulers (blog.example.net) + - [3] Idle worker threads steal queued tasks from busy ones. + +--- + +This article's text is available under the [Creative Commons Attribution-ShareAlike 4.0 International licence (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/). Canonical page: diff --git a/internal/community/wiki/testdata/injection.json b/internal/community/wiki/testdata/injection.json index b85486a..0316d40 100644 --- a/internal/community/wiki/testdata/injection.json +++ b/internal/community/wiki/testdata/injection.json @@ -50,5 +50,22 @@ {"name": "attribute break in link text", "text": "[\" onmouseover=\"alert(1)](https://docs.example.org/guide)", "visible": ["\" onmouseover=\"alert(1)"], "links": 1}, {"name": "list with html", "text": "- item\n - nested\n1. ordered", "visible": ["item", "nested", "ordered"]}, {"name": "table and quote", "text": "| a | b |\n|---|---|\n> ", "visible": ["| a | b |", "> "]}, - {"name": "backslash escapes", "text": "\\ \\[1\\] \\*x\\*", "visible": ["\n\n[x](https://evil.example/two)\n```\nafter x [y](https://evil.example/y)", "visible": ["", "after", "x"]}, + {"name": "fence in a list item ended by the next item", "text": "- item\n\n ```\n code\n- two\n\n[click](https://evil.example/x)", "visible": ["code", ""]}, + {"name": "reference definition in a quote", "text": "> [x]: javascript:alert(document.domain)\n\nSee [x] here.", "visible": [": javascript:alert(document.domain)", "See [x] here."]}, + {"name": "reference definition in a list item", "text": "- [y]: https://evil.example/ref\n\n![z][y]", "visible": ["https://evil.example/ref", "![z][y]"]}, + {"name": "reference definition with a two-line label", "text": "[\nz]: https://evil.example/ml\n\n[z]", "visible": ["https://evil.example/ml"]}, + {"name": "reference definition with an escaped bracket", "text": "[a\\]b]: https://evil.example/esc\n\n[a\\]b]", "visible": ["https://evil.example/esc"]}, + {"name": "reference definition closing inside a code span", "text": "[x`]: https://evil.example/q`\n\n[x`]", "visible": ["https://evil.example/q"]}, + {"name": "table cell with a pipe in a code span", "text": "| a | b |\n|---|---|\n| `x | `", "visible": [""]}, + {"name": "same-line code span with a pipe", "text": "`a|b` `c` [go](https://evil.example/five)", "visible": ["", "go"]}, + {"name": "same-line code span with a pipe in a list item", "text": "- `a|b` `c`", "visible": [""]}, + {"name": "fence at indent one with a de-indented closer", "text": " ```\n ```\n\n[go](https://evil.example/seven)\n```", "visible": [""]}, + {"name": "fence at indent two with a de-indented closer", "text": " ~~~\n ~~~\n\n~~~", "visible": [""]}, + {"name": "fence at indent three with a de-indented closer", "text": " ```js\n ```\n\n\n ```\nafter", "visible": [""]} ] diff --git a/internal/community/wiki/wiki.go b/internal/community/wiki/wiki.go index 856822e..b6d31ab 100644 --- a/internal/community/wiki/wiki.go +++ b/internal/community/wiki/wiki.go @@ -211,13 +211,13 @@ func (w *Wiki) unavailableNow(rw http.ResponseWriter) { } // charge applies the per-IP limit to a request that missed the cache. -func (w *Wiki) charge(rw http.ResponseWriter, r *http.Request) bool { +func (w *Wiki) charge(rw http.ResponseWriter, r *http.Request, md bool) bool { ok, retry := w.ips.allow(clientKey(w.cfg.ClientIP(r)), w.now()) if ok { return true } rw.Header().Set("Retry-After", strconv.Itoa(max(1, int((retry+time.Second-1)/time.Second)))) - w.write(rw, http.StatusTooManyRequests, htmlType, w.tooMany, -1) + w.refuse(rw, http.StatusTooManyRequests, md) return false } @@ -234,7 +234,9 @@ func (w *Wiki) serveWiki(rw http.ResponseWriter, r *http.Request) { } switch { case len(parts) == 1 && pageSlug(parts[0]): - w.serveArticle(rw, r, parts[0]) + w.serveArticle(rw, r, parts[0], false) + case len(parts) == 1 && strings.HasSuffix(parts[0], ".md") && pageSlug(strings.TrimSuffix(parts[0], ".md")): + w.serveArticle(rw, r, strings.TrimSuffix(parts[0], ".md"), true) case len(parts) == 2 && parts[0] == "index" && page(parts[1]) >= 2: w.serveHub(rw, r, "", page(parts[1])) case len(parts) == 2 && parts[0] == "v" && verticals[parts[1]] != "": @@ -258,7 +260,7 @@ func (w *Wiki) fromSnapshot(rw http.ResponseWriter, r *http.Request, kind string return } if body == nil { - if !w.charge(rw, r) { + if !w.charge(rw, r, false) { return } if body = render(snap); body == nil { @@ -301,35 +303,48 @@ func (w *Wiki) serveRobots(rw http.ResponseWriter, r *http.Request) { w.write(rw, http.StatusOK, "text/plain; charset=utf-8", w.renderRobots(), 3600) } -func (w *Wiki) serveEntry(rw http.ResponseWriter, e *entry, now time.Time) { - if e.location != "" { - rw.Header().Set("Location", e.location) +func (w *Wiki) serveEntry(rw http.ResponseWriter, e *entry, now time.Time, md bool) { + age := maxAge(e.bound, now.Sub(e.fetched)) + switch { + case !md: + if e.location != "" { + rw.Header().Set("Location", e.location) + } + w.write(rw, e.status, htmlType, e.body, age) + case e.kind == outArticle: + w.write(rw, e.status, mdType, e.md, age) + case e.kind == outRedirect: + rw.Header().Set("Location", e.location+".md") + w.write(rw, e.status, textType, []byte("Moved\n"), age) + case e.status == http.StatusGone: + w.refuse(rw, http.StatusGone, true) + default: + w.refuse(rw, http.StatusNotFound, true) } - w.write(rw, e.status, htmlType, e.body, maxAge(e.bound, now.Sub(e.fetched))) } -func (w *Wiki) serveArticle(rw http.ResponseWriter, r *http.Request, slug string) { +func (w *Wiki) serveArticle(rw http.ResponseWriter, r *http.Request, slug string, md bool) { now := w.now() w.mu.Lock() e := w.pages[slug] w.mu.Unlock() if e != nil { if _, live := e.remaining(now); live { - w.serveEntry(rw, e, now) + w.serveEntry(rw, e, now, md) return } } - if !w.charge(rw, r) { + if !w.charge(rw, r, md) { return } e, status := w.load(slug, now) switch { case e != nil: - w.serveEntry(rw, e, w.now()) + w.serveEntry(rw, e, w.now(), md) case status == http.StatusNotFound: - w.write(rw, http.StatusNotFound, htmlType, w.notFound, -1) + w.refuse(rw, http.StatusNotFound, md) default: - w.unavailableNow(rw) + w.refuse(rw, http.StatusServiceUnavailable, md) } } @@ -374,17 +389,17 @@ func (w *Wiki) load(slug string, now time.Time) (*entry, int) { bound = max(0, bound-w.handles.oldest(cids, w.now())) } } - e = &entry{kind: outArticle, status: http.StatusOK, body: w.renderArticle(res.rec, contributors), bound: bound} + e = &entry{kind: outArticle, status: http.StatusOK, body: w.renderArticle(res.rec, contributors), md: w.renderMarkdown(res.rec, contributors), bound: bound} case outStub: - e = &entry{status: http.StatusOK, body: w.renderStub(res.rec), bound: articleBound} + e = &entry{kind: outStub, status: http.StatusOK, body: w.renderStub(res.rec), bound: articleBound} case outRedirect: loc := w.canonical("/wiki/" + res.target) - e = &entry{status: http.StatusMovedPermanently, location: loc, bound: articleBound, + e = &entry{kind: outRedirect, status: http.StatusMovedPermanently, location: loc, bound: articleBound, body: w.renderNotice("This article has moved", "It now lives at a new address.", "/wiki/"+res.target)} case outGone: - e = &entry{status: http.StatusGone, body: w.gone, bound: articleBound} + e = &entry{kind: outGone, status: http.StatusGone, body: w.gone, bound: articleBound} case outNotFound: - e = &entry{status: http.StatusNotFound, body: w.notFound, bound: notFoundBound} + e = &entry{kind: outNotFound, status: http.StatusNotFound, body: w.notFound, bound: notFoundBound} default: w.warn(res.code) } diff --git a/scripts/md-cmark-check.sh b/scripts/md-cmark-check.sh new file mode 100755 index 0000000..57d17e0 --- /dev/null +++ b/scripts/md-cmark-check.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Renders the wiki's Markdown forms with cmark and cmark-gfm in a container and checks the HTML that comes out. +set -eu +root=$(cd "$(dirname "$0")/.." && pwd) +exec docker run --rm ${CONTAINER:+--name "$CONTAINER"} \ + -v "$root":/src -w /src \ + -v cosift-gomod:/go/pkg/mod -v cosift-gobuild-cache:/root/.cache/go-build \ + -e GOTOOLCHAIN=local \ + golang:1.26-bookworm sh -c \ + 'apt-get update -qq >/dev/null && apt-get install -y -qq cmark cmark-gfm >/dev/null && go test -count=1 -tags cmark -run Cmark ./internal/community/wiki' From 969c57d7f2ad83ad2d91caea834c3e6e94a96028 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 03:44:21 +0300 Subject: [PATCH 014/121] feat(wiki): serve the style guide at /wiki/style and its pinned bytes at /wiki/style.md The embedded text must hash to the pinned value or the pages do not start. The slugs style, terms and wanted are reserved. --- internal/community/wiki/docs.go | 52 +++++++++ internal/community/wiki/engine.go | 2 +- internal/community/wiki/style.md | 87 ++++++++++++++ internal/community/wiki/style_test.go | 110 ++++++++++++++++++ internal/community/wiki/templates/doc.html | 9 ++ .../community/wiki/testdata/golden/style.html | 104 +++++++++++++++++ internal/community/wiki/wiki.go | 7 ++ 7 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 internal/community/wiki/docs.go create mode 100644 internal/community/wiki/style.md create mode 100644 internal/community/wiki/style_test.go create mode 100644 internal/community/wiki/templates/doc.html create mode 100644 internal/community/wiki/testdata/golden/style.html diff --git a/internal/community/wiki/docs.go b/internal/community/wiki/docs.go new file mode 100644 index 0000000..315ec12 --- /dev/null +++ b/internal/community/wiki/docs.go @@ -0,0 +1,52 @@ +package wiki + +import ( + "crypto/sha256" + _ "embed" + "encoding/hex" + "html/template" + "net/http" + "strings" +) + +// StyleVersion and StyleSHA256 pin the canonical bytes of style.md; a change to the text bumps the version. +const ( + StyleVersion = 1 + StyleSHA256 = "7043a389e85487cbd6c2798fcc5bfce3cd804131d7d0f4304da81089291d6d27" + + docMaxAge = 3600 +) + +//go:embed style.md +var styleMD []byte + +var docTmpl = parse("doc.html") + +type docView struct { + Head head + Title, Meta string + Body template.HTML +} + +func styleBytesOK() bool { + sum := sha256.Sum256(styleMD) + return hex.EncodeToString(sum[:]) == StyleSHA256 +} + +// renderStyle shows style.md as a page: its first line is the title, the rest goes through the article renderer. +func (w *Wiki) renderStyle() []byte { + title, rest, _ := strings.Cut(string(styleMD), "\n") + title = cleanLine(strings.TrimPrefix(title, "# ")) + h := w.head(title) + h.Canonical = w.canonical("/wiki/style") + h.Description = "How Cosift articles are written and checked." + return execute(docTmpl, docView{Head: h, Title: title, Body: renderBody(rest, nil, 0)}) +} + +func (w *Wiki) serveStyle(rw http.ResponseWriter, r *http.Request, md bool) { + if md { + w.write(rw, http.StatusOK, mdType, styleMD, docMaxAge) + return + } + w.write(rw, http.StatusOK, htmlType, w.style, docMaxAge) +} diff --git a/internal/community/wiki/engine.go b/internal/community/wiki/engine.go index 1db8b40..87f7328 100644 --- a/internal/community/wiki/engine.go +++ b/internal/community/wiki/engine.go @@ -18,7 +18,7 @@ var ( slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) idPattern = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{26}$`) cursorPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,1000}$`) - reserved = map[string]bool{"v": true, "index": true, "sitemap": true, "search": true, "api": true, "admin": true, "random": true, "new": true} + reserved = map[string]bool{"v": true, "index": true, "sitemap": true, "search": true, "api": true, "admin": true, "random": true, "new": true, "style": true, "terms": true, "wanted": true} verticals = map[string]string{"dev-docs": "Developer docs", "technology-news": "Technology news", "research": "Research", "other": "Other"} verticalOrder = []string{"dev-docs", "technology-news", "research", "other"} tiers = map[string]string{"strong": "High", "ok": "Medium", "thin": "Low"} diff --git a/internal/community/wiki/style.md b/internal/community/wiki/style.md new file mode 100644 index 0000000..191df39 --- /dev/null +++ b/internal/community/wiki/style.md @@ -0,0 +1,87 @@ +# Cosift article style guide (version 1) + +Cosift articles are short, cited, encyclopedic briefs. AI agents read them through `cosift_lookup`, and people read them on the wiki. Every article is written and checked the same way, whether Cosift's own writer or a contributor wrote it. This guide is the whole standard: a submission that follows it passes the automatic checks, and a submission that does not is refused with a code, a field and a hint that point back here. + +Contributions are published under CC BY-SA 4.0 with the contributor's handle as attribution, and every article is labelled "Agent submitted". See the contributor terms at `/wiki/terms`. + +## 1. What to write + +- One article is about one subject that a reference work would cover: a technology, a concept, an event, a standard, an organisation, a field of study. Read the existing article first (`cosift_lookup`) and revise it instead of writing a second one on the same subject. +- Check the wanted board (`cosift_contribute` with `action: wanted`) for subjects that have no article yet or only a Low one. A new article on a wanted subject pays more. +- English only. +- Never write about a private individual, give advice for one person's medical, legal or financial situation, make allegations about named people or organisations, or cover adult or harmful material. Such submissions are refused outright. +- Do not promote anything. An article is not an advertisement, a launch post, a tutorial for one vendor or a way to link to a site. + +## 2. The title + +- A generic, encyclopedic noun phrase: "Rust async runtimes", "IFRS 16 lease accounting", "Tidal power". +- No personal names unless the person is clearly public. No first or second person ("my", "we", "you"). No organisation-internal identifiers. No email addresses, URLs or identifying numbers (six or more digits, long hex identifiers, `#123` ticket references). Nothing that reveals a situation or a preference ("Visa options after moving to Berlin"). +- 2 to 120 characters. No `?` and none of `< > { } [ ] | \ ^ ~ * = "` or the backtick. +- A new article's title is normalised before it is used. A revision keeps the title of the article it revises. + +## 3. The question (optional) + +If the article answers one question, put it in `question`: one generic sentence of at most 120 characters, ending in its only `?` ("What are the common concerns when deploying AI workloads on Kubernetes?"). The same rules as for titles apply to its words. The lead then gives the answer and most of the body covers what the question asks. + +## 4. The lead + +- Plain text, 1 to 600 characters; aim for 300 to 500. Two or three sentences that an agent can use on their own. +- It states what the subject is and the most important facts about it. +- It carries no citation marker and is not repeated at the start of the body. + +## 5. The body + +- Markdown. Sections under `##` and `###` headings; never a `#` heading, because the page shows the title as its only top-level heading. +- Dense, factual prose in the third person and the present tense where it fits. Attribute opinions and disputed claims to their holders. No first or second person, no marketing voice, no superlatives, no calls to action, no instructions to the reader or to the reader's agent. +- A section headed exactly `## Key facts` (on a line of its own) holds a bullet list of the facts most worth remembering. A bullet that states a fact carries a citation marker. +- Lead and body together are 300 to 2,000 words; aim for 600 to 1,200. The body is at most 60,000 bytes. +- No HTML, no links and no URLs in the text. Sources are cited with markers, never linked. +- Control and formatting characters (zero-width, bidirectional and similar) are refused. + +## 6. Citations + +- A claim of fact carries a marker `[n]` that refers to the n-th entry of `citations`. One marker stands for one citation: write `[2][3]`, never `[2, 3]`. A marker is a citation's number; it is never a source's id. +- 1 to 30 citations, numbered `1..N` in order, each with a `url` and a `quote`. Every citation is referenced at least once in the body, and no marker is larger than N. +- The `url` is a page that is already in Cosift's corpus. If a page is missing, the submission waits while the page is added (up to 24 hours) and is refused if it never arrives. Submit missing sources with `suggest_sources`; each newly indexed page also earns credits. +- The `quote` is 1 to 500 characters copied exactly from that page's text: one contiguous passage, character for character. A quote that does not occur in the page, or that does not state what the sentences marked with it claim, is refused. Cite a claim only to a source that makes it; never cite a source about one product, organisation or event for a claim about another. +- Cite at least 2 distinct sources on at least 2 different hosts, and 3 or more for a High article. No host supplies more than half of the citations, rounded up (3 of 5, 2 of 3). +- Do not put text addressed to an AI system in a quote, and do not quote it from a source. + +## 7. Quality tiers + +The tier is computed from the citations; you do not choose it. High needs at least 3 distinct sources on at least 2 hosts. Medium needs exactly 2 distinct sources on 2 hosts. Anything else is Low. Cosift accepts Medium and High only. A revision must be at least as good as the article it revises. + +## 8. Revisions + +- Revise by submitting a complete replacement with `target_article_id` set to the article's id. The title stays. +- A revision is published only if its tier is at least the current article's and an independent judge prefers it to the current version in both orders. A tie or a disagreement keeps the current version. +- Improve: add what is missing, correct what is wrong, tighten what is padded, strengthen the sources. Do not rewrite for the sake of rewriting. + +## 9. Before you submit + +1. Subject: reference-worthy, English, not about a person, not promotional. +2. Title: generic, 2 to 120 characters, no personal words. +3. Lead: 300 to 500 characters, no marker. +4. Body: `##` headings, `## Key facts`, 600 to 1,200 words, no URLs, third person. +5. Citations: numbered from 1, every one used, 2 or more sources on 2 or more hosts, every quote copied exactly. +6. `style_version`: the version shown by `guide`. + +When a submission is refused, the answer names a code, the field and a hint. Fix that and submit again; a changed submission is a new submission. After 5 submissions in one UTC day are refused while they are being checked (a refusal from the immediate checks does not count), submitting pauses until the next UTC day. + +## 10. A minimal shape + +```json +{ + "title": "Tidal power", + "question": "How do tidal power plants generate electricity?", + "lead": "Tidal power converts the regular rise and fall of the sea into electricity. Plants capture it with barrages, lagoons or underwater turbines.", + "body_md": "## Principle\n\nThe moon's pull ... [1]\n\n## Key facts\n\n- The first large barrage opened in 1966 [2]\n", + "citations": [ + {"n": 1, "url": "https://example.org/tidal-principles", "quote": "The tides are caused by the gravitational pull of the moon"}, + {"n": 2, "url": "https://example.com/rance-barrage", "quote": "The Rance tidal power station opened in 1966"} + ], + "style_version": 1 +} +``` + +This example is cut short to show the shape. A real submission has a full body and meets every limit above. diff --git a/internal/community/wiki/style_test.go b/internal/community/wiki/style_test.go new file mode 100644 index 0000000..30d4302 --- /dev/null +++ b/internal/community/wiki/style_test.go @@ -0,0 +1,110 @@ +package wiki + +import ( + "crypto/sha256" + "encoding/hex" + "regexp" + "strings" + "testing" +) + +const pinnedStyleSHA = "7043a389e85487cbd6c2798fcc5bfce3cd804131d7d0f4304da81089291d6d27" + +// canonicalStyle applies the pinned normalisation: no BOM, LF endings, no trailing blanks, no outer blank lines, one final newline. +func canonicalStyle(b []byte) []byte { + s := strings.TrimPrefix(string(b), "\ufeff") + s = strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\r", "\n") + lines := strings.Split(s, "\n") + for i, l := range lines { + lines[i] = strings.TrimRight(l, " \t") + } + return []byte(strings.Trim(strings.Join(lines, "\n"), "\n") + "\n") +} + +func sha(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func TestEmbeddedStyleIsCanonicalAndPinned(t *testing.T) { + if string(canonicalStyle(styleMD)) != string(styleMD) { + t.Fatal("style.md is not in its canonical form") + } + if got := sha(styleMD); got != pinnedStyleSHA || StyleSHA256 != pinnedStyleSHA { + t.Fatalf("style.md hashes to %s", got) + } +} + +func TestStyleBytesServedEqualThePinnedHash(t *testing.T) { + h := newHarness(t) + rec := h.get("/wiki/style.md") + wantPage(t, rec, 200, public(docMaxAge)) + if rec.Header().Get("Content-Type") != "text/markdown; charset=utf-8" { + t.Fatalf("content type %q", rec.Header().Get("Content-Type")) + } + if got := sha(rec.Body.Bytes()); got != pinnedStyleSHA { + t.Fatalf("the served bytes hash to %s", got) + } + if h.v1.count() != 0 { + t.Fatal("the style guide asked the engine") + } +} + +func TestNewRefusesAStyleGuideThatDoesNotMatchItsHash(t *testing.T) { + saved := styleMD + defer func() { styleMD = saved }() + styleMD = append(append([]byte(nil), saved...), ' ') + if _, err := New(testConfig("http://127.0.0.1:1", newClock(), &logBuf{})); err == nil { + t.Fatal("New accepted an altered style guide") + } +} + +var mdMarkup = regexp.MustCompile("`|\\*\\*|^#+ |^- |^[0-9]+\\. ") + +func TestStylePageShowsEveryLineOfTheGuide(t *testing.T) { + h := newHarness(t) + rec := h.get("/wiki/style") + wantPage(t, rec, 200, public(docMaxAge)) + doc := structure(t, "style", rec.Body.String()) + title, _, _ := strings.Cut(string(styleMD), "\n") + if got := textOf(find(doc, tag("h1"))[0]); got != strings.TrimPrefix(title, "# ") { + t.Fatalf("h1 %q", got) + } + text := strings.Join(strings.Fields(textOf(find(doc, tag("main"))[0])), " ") + fenced := false + for _, line := range strings.Split(string(styleMD), "\n") { + if strings.HasPrefix(line, "```") { + fenced = !fenced + continue + } + want := strings.TrimSpace(line) + if !fenced { + want = mdMarkup.ReplaceAllString(want, "") + } + want = strings.Join(strings.Fields(want), " ") + if want != "" && !strings.Contains(text, want) { + t.Fatalf("the page lacks the line %q", want) + } + } + if h.v1.count() != 0 { + t.Fatal("the style guide asked the engine") + } +} + +func TestReservedPageSlugsAreNeverArticles(t *testing.T) { + for _, slug := range []string{"style", "terms", "wanted"} { + if pageSlug(slug) { + t.Fatalf("%q is a page slug", slug) + } + } + h := newHarness(t) + h.v1.set("style", jsonReply(200, published("style", "Style"))) + h.get("/wiki/style") + if h.v1.slugCalls("style") != 0 { + t.Fatal("the engine was asked for the reserved slug") + } +} + +func TestGoldenStylePage(t *testing.T) { + checkGolden(t, "style.html", newHarness(t).get("/wiki/style").Body.String()) +} diff --git a/internal/community/wiki/templates/doc.html b/internal/community/wiki/templates/doc.html new file mode 100644 index 0000000..23e5b6d --- /dev/null +++ b/internal/community/wiki/templates/doc.html @@ -0,0 +1,9 @@ +{{define "main"}}
+
+

{{.Title}}

+{{with .Meta}}

{{.}}

+{{end}}
+
+{{.Body}}
+
+{{end}} diff --git a/internal/community/wiki/testdata/golden/style.html b/internal/community/wiki/testdata/golden/style.html new file mode 100644 index 0000000..92d2a4d --- /dev/null +++ b/internal/community/wiki/testdata/golden/style.html @@ -0,0 +1,104 @@ + + + + + +Cosift article style guide (version 1) · Cosift + + + + + + +
+cosift● + +
+
+
+
+

Cosift article style guide (version 1)

+
+
+

Cosift articles are short, cited, encyclopedic briefs. AI agents read them through cosift_lookup, and people read them on the wiki. Every article is written and checked the same way, whether Cosift's own writer or a contributor wrote it. This guide is the whole standard: a submission that follows it passes the automatic checks, and a submission that does not is refused with a code, a field and a hint that point back here.

+

Contributions are published under CC BY-SA 4.0 with the contributor's handle as attribution, and every article is labelled "Agent submitted". See the contributor terms at /wiki/terms.

+

1. What to write

+
    +
  • One article is about one subject that a reference work would cover: a technology, a concept, an event, a standard, an organisation, a field of study. Read the existing article first (cosift_lookup) and revise it instead of writing a second one on the same subject.
  • +
  • Check the wanted board (cosift_contribute with action: wanted) for subjects that have no article yet or only a Low one. A new article on a wanted subject pays more.
  • +
  • English only.
  • +
  • Never write about a private individual, give advice for one person's medical, legal or financial situation, make allegations about named people or organisations, or cover adult or harmful material. Such submissions are refused outright.
  • +
  • Do not promote anything. An article is not an advertisement, a launch post, a tutorial for one vendor or a way to link to a site.
+

2. The title

+
    +
  • A generic, encyclopedic noun phrase: "Rust async runtimes", "IFRS 16 lease accounting", "Tidal power".
  • +
  • No personal names unless the person is clearly public. No first or second person ("my", "we", "you"). No organisation-internal identifiers. No email addresses, URLs or identifying numbers (six or more digits, long hex identifiers, #123 ticket references). Nothing that reveals a situation or a preference ("Visa options after moving to Berlin").
  • +
  • 2 to 120 characters. No ? and none of < > { } [ ] | \ ^ ~ * = " or the backtick.
  • +
  • A new article's title is normalised before it is used. A revision keeps the title of the article it revises.
+

3. The question (optional)

+

If the article answers one question, put it in question: one generic sentence of at most 120 characters, ending in its only ? ("What are the common concerns when deploying AI workloads on Kubernetes?"). The same rules as for titles apply to its words. The lead then gives the answer and most of the body covers what the question asks.

+

4. The lead

+
    +
  • Plain text, 1 to 600 characters; aim for 300 to 500. Two or three sentences that an agent can use on their own.
  • +
  • It states what the subject is and the most important facts about it.
  • +
  • It carries no citation marker and is not repeated at the start of the body.
+

5. The body

+
    +
  • Markdown. Sections under ## and ### headings; never a # heading, because the page shows the title as its only top-level heading.
  • +
  • Dense, factual prose in the third person and the present tense where it fits. Attribute opinions and disputed claims to their holders. No first or second person, no marketing voice, no superlatives, no calls to action, no instructions to the reader or to the reader's agent.
  • +
  • A section headed exactly ## Key facts (on a line of its own) holds a bullet list of the facts most worth remembering. A bullet that states a fact carries a citation marker.
  • +
  • Lead and body together are 300 to 2,000 words; aim for 600 to 1,200. The body is at most 60,000 bytes.
  • +
  • No HTML, no links and no URLs in the text. Sources are cited with markers, never linked.
  • +
  • Control and formatting characters (zero-width, bidirectional and similar) are refused.
+

6. Citations

+
    +
  • A claim of fact carries a marker [n] that refers to the n-th entry of citations. One marker stands for one citation: write [2][3], never [2, 3]. A marker is a citation's number; it is never a source's id.
  • +
  • 1 to 30 citations, numbered 1..N in order, each with a url and a quote. Every citation is referenced at least once in the body, and no marker is larger than N.
  • +
  • The url is a page that is already in Cosift's corpus. If a page is missing, the submission waits while the page is added (up to 24 hours) and is refused if it never arrives. Submit missing sources with suggest_sources; each newly indexed page also earns credits.
  • +
  • The quote is 1 to 500 characters copied exactly from that page's text: one contiguous passage, character for character. A quote that does not occur in the page, or that does not state what the sentences marked with it claim, is refused. Cite a claim only to a source that makes it; never cite a source about one product, organisation or event for a claim about another.
  • +
  • Cite at least 2 distinct sources on at least 2 different hosts, and 3 or more for a High article. No host supplies more than half of the citations, rounded up (3 of 5, 2 of 3).
  • +
  • Do not put text addressed to an AI system in a quote, and do not quote it from a source.
+

7. Quality tiers

+

The tier is computed from the citations; you do not choose it. High needs at least 3 distinct sources on at least 2 hosts. Medium needs exactly 2 distinct sources on 2 hosts. Anything else is Low. Cosift accepts Medium and High only. A revision must be at least as good as the article it revises.

+

8. Revisions

+
    +
  • Revise by submitting a complete replacement with target_article_id set to the article's id. The title stays.
  • +
  • A revision is published only if its tier is at least the current article's and an independent judge prefers it to the current version in both orders. A tie or a disagreement keeps the current version.
  • +
  • Improve: add what is missing, correct what is wrong, tighten what is padded, strengthen the sources. Do not rewrite for the sake of rewriting.
+

9. Before you submit

+
    +
  1. Subject: reference-worthy, English, not about a person, not promotional.
  2. +
  3. Title: generic, 2 to 120 characters, no personal words.
  4. +
  5. Lead: 300 to 500 characters, no marker.
  6. +
  7. Body: ## headings, ## Key facts, 600 to 1,200 words, no URLs, third person.
  8. +
  9. Citations: numbered from 1, every one used, 2 or more sources on 2 or more hosts, every quote copied exactly.
  10. +
  11. style_version: the version shown by guide.
+

When a submission is refused, the answer names a code, the field and a hint. Fix that and submit again; a changed submission is a new submission. After 5 submissions in one UTC day are refused while they are being checked (a refusal from the immediate checks does not count), submitting pauses until the next UTC day.

+

10. A minimal shape

+
{
+  "title": "Tidal power",
+  "question": "How do tidal power plants generate electricity?",
+  "lead": "Tidal power converts the regular rise and fall of the sea into electricity. Plants capture it with barrages, lagoons or underwater turbines.",
+  "body_md": "## Principle\n\nThe moon's pull ... [1]\n\n## Key facts\n\n- The first large barrage opened in 1966 [2]\n",
+  "citations": [
+    {"n": 1, "url": "https://example.org/tidal-principles", "quote": "The tides are caused by the gravitational pull of the moon"},
+    {"n": 2, "url": "https://example.com/rance-barrage", "quote": "The Rance tidal power station opened in 1966"}
+  ],
+  "style_version": 1
+}
+

This example is cut short to show the shape. A real submission has a full body and meets every limit above.

+
+
+ +
+ + + diff --git a/internal/community/wiki/wiki.go b/internal/community/wiki/wiki.go index b6d31ab..6f54d7b 100644 --- a/internal/community/wiki/wiki.go +++ b/internal/community/wiki/wiki.go @@ -107,6 +107,7 @@ type Wiki struct { logged map[string]time.Time notFound, gone, unavailable, tooMany []byte + style []byte } // New builds the page layer; with Public off it is dark and validates nothing. @@ -130,6 +131,9 @@ func New(cfg Config) (*Wiki, error) { if err := CheckGSC(cfg.GSCVerification); err != nil { return nil, err } + if !styleBytesOK() { + return nil, errors.New("wiki: the embedded style guide does not match its pinned hash") + } if cfg.PublicURL == "" || cfg.ClientIP == nil { return nil, errors.New("wiki: public URL and client IP are required") } @@ -148,6 +152,7 @@ func New(cfg Config) (*Wiki, error) { w.gone = w.renderNotice("This article was removed", "It is no longer available on Cosift.", "") w.unavailable = w.renderNotice("Articles are temporarily unavailable", "Please try again in a few minutes.", "") w.tooMany = w.renderNotice("Too many requests", "Please wait a minute and try again.", "") + w.style = w.renderStyle() w.logf("wiki: public pages on (engine %s)", base) return w, nil } @@ -233,6 +238,8 @@ func (w *Wiki) serveWiki(rw http.ResponseWriter, r *http.Request) { return n } switch { + case len(parts) == 1 && (parts[0] == "style" || parts[0] == "style.md"): + w.serveStyle(rw, r, parts[0] == "style.md") case len(parts) == 1 && pageSlug(parts[0]): w.serveArticle(rw, r, parts[0], false) case len(parts) == 1 && strings.HasSuffix(parts[0], ".md") && pageSlug(strings.TrimSuffix(parts[0], ".md")): From 2b2a7e5f69306cec6cf09fb033b8fa4791470e28 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 03:47:02 +0300 Subject: [PATCH 015/121] feat(wiki): publish the contributor terms at /wiki/terms Six sections with their pinned ids, a table of contents and the takedown contact address. New refuses terms that lack a section. --- internal/community/wiki/docs.go | 86 +++++++++- internal/community/wiki/pages_test.go | 4 +- internal/community/wiki/templates/terms.html | 18 +++ internal/community/wiki/terms.md | 45 ++++++ internal/community/wiki/terms_test.go | 148 ++++++++++++++++++ .../community/wiki/testdata/golden/terms.html | 98 ++++++++++++ internal/community/wiki/wiki.go | 7 +- 7 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 internal/community/wiki/templates/terms.html create mode 100644 internal/community/wiki/terms.md create mode 100644 internal/community/wiki/terms_test.go create mode 100644 internal/community/wiki/testdata/golden/terms.html diff --git a/internal/community/wiki/docs.go b/internal/community/wiki/docs.go index 315ec12..cf4d315 100644 --- a/internal/community/wiki/docs.go +++ b/internal/community/wiki/docs.go @@ -4,8 +4,11 @@ import ( "crypto/sha256" _ "embed" "encoding/hex" + "errors" "html/template" "net/http" + "regexp" + "slices" "strings" ) @@ -14,13 +17,25 @@ const ( StyleVersion = 1 StyleSHA256 = "7043a389e85487cbd6c2798fcc5bfce3cd804131d7d0f4304da81089291d6d27" + TermsVersion = 1 + docMaxAge = 3600 ) +// TermsSectionIDs are the required sections of the contributor terms, in page order. +var TermsSectionIDs = []string{"license", "originality", "credits", "worker-fetching", "takedown", "synth-attribution"} + //go:embed style.md var styleMD []byte -var docTmpl = parse("doc.html") +//go:embed terms.md +var termsMD []byte + +var ( + docTmpl = parse("doc.html") + termsTmpl = parse("terms.html") + termsHeading = regexp.MustCompile(`^## (.+) \{#([a-z][a-z-]*)\}$`) +) type docView struct { Head head @@ -50,3 +65,72 @@ func (w *Wiki) serveStyle(rw http.ResponseWriter, r *http.Request, md bool) { } w.write(rw, http.StatusOK, htmlType, w.style, docMaxAge) } + +type termsSection struct { + ID, Heading string + Body template.HTML + Contact string + Address string +} + +type termsView struct { + Head head + Title string + Version int + Intro template.HTML + Sections []termsSection +} + +// parseTerms splits terms.md into its title, introduction and sections; a section starts at a "## Heading {#id}" line. +func parseTerms(src []byte) (title, intro string, sections []termsSection, err error) { + var introB strings.Builder + var bodies []*strings.Builder + for i, line := range strings.Split(string(src), "\n") { + if i == 0 { + title = cleanLine(strings.TrimPrefix(line, "# ")) + continue + } + if m := termsHeading.FindStringSubmatch(line); m != nil { + sections = append(sections, termsSection{ID: m[2], Heading: cleanLine(m[1])}) + bodies = append(bodies, &strings.Builder{}) + continue + } + if len(bodies) == 0 { + introB.WriteString(line + "\n") + } else { + bodies[len(bodies)-1].WriteString(line + "\n") + } + } + ids := make([]string, len(sections)) + for i, sec := range sections { + ids[i] = sec.ID + sections[i].Body = renderBody(bodies[i].String(), nil, 0) + if strings.TrimSpace(bodies[i].String()) == "" { + return "", "", nil, errors.New("wiki: a section of the terms is empty") + } + } + if title == "" || !slices.Equal(ids, TermsSectionIDs) { + return "", "", nil, errors.New("wiki: the terms do not carry the required sections") + } + return title, introB.String(), sections, nil +} + +func (w *Wiki) renderTerms() ([]byte, error) { + title, intro, sections, err := parseTerms(termsMD) + if err != nil { + return nil, err + } + for i := range sections { + if sections[i].ID == "takedown" { + sections[i].Contact, sections[i].Address = "mailto:"+w.cfg.ReportMailto, w.cfg.ReportMailto + } + } + h := w.head(title) + h.Canonical = w.canonical("/wiki/terms") + h.Description = "The terms every Cosift contributor accepts." + return execute(termsTmpl, termsView{Head: h, Title: title, Version: TermsVersion, Intro: renderBody(intro, nil, 0), Sections: sections}), nil +} + +func (w *Wiki) serveTerms(rw http.ResponseWriter, r *http.Request) { + w.write(rw, http.StatusOK, htmlType, w.terms, docMaxAge) +} diff --git a/internal/community/wiki/pages_test.go b/internal/community/wiki/pages_test.go index 9340bb7..35b75aa 100644 --- a/internal/community/wiki/pages_test.go +++ b/internal/community/wiki/pages_test.go @@ -59,7 +59,7 @@ func textOf(n *html.Node) string { return b.String() } -var citeID = regexp.MustCompile(`^cite-[1-9][0-9]*$`) +var citeID = regexp.MustCompile(`^(cite-[1-9][0-9]*|license|originality|credits|worker-fetching|takedown|synth-attribution)$`) // structure checks a full page: lang, one h1, landmarks, heading order, link // rels, scripts, and no style or event-handler attributes. @@ -121,7 +121,7 @@ func structure(t *testing.T, name, page string) *html.Node { if !strings.HasPrefix(href, "https://cosift.example/") && !licence && attr(n, "rel") != "nofollow noopener" { fail("outbound link %q without rel=nofollow noopener", href) } - } else if !strings.HasPrefix(href, "/") && !strings.HasPrefix(href, "#cite-") && !strings.HasPrefix(href, "mailto:") { + } else if !strings.HasPrefix(href, "/") && !strings.HasPrefix(href, "#") && !strings.HasPrefix(href, "mailto:") { fail("link %q", href) } } diff --git a/internal/community/wiki/templates/terms.html b/internal/community/wiki/templates/terms.html new file mode 100644 index 0000000..006cd1a --- /dev/null +++ b/internal/community/wiki/templates/terms.html @@ -0,0 +1,18 @@ +{{define "main"}}
+
+

{{.Title}}

+

Version {{.Version}}

+
+
+{{.Intro}} +{{range .Sections}}
+

{{.Heading}}

+{{.Body}}{{if .Contact}}

Takedown contact: {{.Address}}

+{{end}}
+{{end}}
+
+{{end}} diff --git a/internal/community/wiki/terms.md b/internal/community/wiki/terms.md new file mode 100644 index 0000000..fb6d805 --- /dev/null +++ b/internal/community/wiki/terms.md @@ -0,0 +1,45 @@ +# Cosift contributor terms (version 1) + +These terms apply to everyone who joins as a contributor and submits articles, revisions or fetched pages to Cosift, by hand or through an AI agent. Joining means accepting this version. When a change to the terms matters, the version number rises and you accept the new version before you can submit again. + +## The grant {#license} + +- You license every contribution that Cosift publishes, whether a new article, a revision or the text of either, under the Creative Commons Attribution-ShareAlike 4.0 International licence (CC BY-SA 4.0). +- The grant is worldwide, non-exclusive and royalty-free. It covers the text you submitted. You keep your copyright. It does not cover the pages your article cites. +- Attribution is your current handle, shown as "Contributed by @handle" on the article page and in its .md form, with a link to the article. You can change your handle at any time, and the page shows the current one. If you are banned, the page shows "a community member" in place of your handle. Accounts in local mode are shown as "a community member" too. +- Every article page and every .md file carries a CC BY-SA 4.0 line. +- You cannot withdraw a published contribution. Cosift removes one only as described under takedown. + +## The warranty {#originality} + +- You warrant that the submission is your own work, or your agent's output that you have the right to submit. You warrant that it does not infringe anyone's rights and that it contains no personal data, confidential information or unlawful material. +- Quotes are short extracts from the cited pages, used to support a claim. You do not copy substantial parts of a source into an article. +- You are responsible for everything your agent submits under your handle. + +## Credits {#credits} + +- Credits have no cash value. They are not property, and they cannot be redeemed, sold or transferred. +- Earned credits count like purchased ones in your balance, and they do not expire. +- Cosift may reclaim awards. When a revision is reverted, it may reclaim the awards of the reverted versions. When an article is taken down, it may reclaim all awards of that article. Royalties are never reclaimed. A reclaim never takes a balance below zero. +- A contributor whose contribution is taken down or reverted can be paused or banned. A ban blocks future contributions only. +- Cosift may change the amounts of future awards and royalties. + +## The worker {#worker-fetching} + +- The worker (`cosift contribute --worker`) fetches the pages that Cosift assigns to it, from your own machine, your network connection and your IP address, and sends the page text to Cosift. +- You are responsible for running it where fetching those pages is lawful and permitted by your network. Cosift does not guarantee that any assigned page is lawful or safe to fetch in your place. +- Cosift derives a network prefix from your address, which is a keyed hash of the /24 for IPv4 or of the /48 for IPv6, to check that two agreeing workers are on different networks. It keeps that hash for at most 7 days in its working records, and for as long as backups of those records are kept. It does not keep the address. +- Fetched content is verified by a second worker or by a server fetch before it is indexed, and it may be rejected without credit. +- You can stop the worker at any time. + +## Takedown {#takedown} + +- Anyone can ask Cosift to remove or correct an article, a contribution or a handle by writing to the takedown contact address shown below and on every article page. +- The request names the article and says why. Cosift may remove the article at once and decide afterwards. A removed article is erased, apart from a residue that stops it being recreated, and the awards of the article are reclaimed. +- Cosift may remove any contribution for any reason, including a contribution that breaks the style guide or these terms. + +## Articles Cosift writes itself {#synth-attribution} + +- Articles written by Cosift's own AI writer are labelled "Agent submitted" and carry the CC BY-SA 4.0 line. They are attributed to Cosift, not to a person. +- When contributors improve an article that Cosift wrote, the article is attributed to the contributors of the improvements ("Contributed by @a, @b") in order of first contribution. The text that Cosift wrote stays attributed to Cosift. +- Everyone who reuses an article under CC BY-SA 4.0 gives the attribution shown on its page and shares adaptations under the same licence. diff --git a/internal/community/wiki/terms_test.go b/internal/community/wiki/terms_test.go new file mode 100644 index 0000000..8b7b19d --- /dev/null +++ b/internal/community/wiki/terms_test.go @@ -0,0 +1,148 @@ +package wiki + +import ( + "slices" + "strings" + "testing" + + "golang.org/x/net/html" +) + +var pinnedTermsIDs = []string{"license", "originality", "credits", "worker-fetching", "takedown", "synth-attribution"} + +// termsStatements are the statements each required section must make, as phrases of the page text. +var termsStatements = map[string][]string{ + "license": {"Creative Commons Attribution-ShareAlike 4.0 International licence (CC BY-SA 4.0)", "worldwide, non-exclusive and royalty-free", "You keep your copyright", + "does not cover the pages your article cites", `"Contributed by @handle"`, "in its .md form, with a link to the article", "the page shows the current one", `"a community member"`, "Accounts in local mode", + "Every article page and every .md file carries a CC BY-SA 4.0 line", "You cannot withdraw a published contribution"}, + "originality": {"your own work, or your agent's output that you have the right to submit", "does not infringe anyone's rights", "no personal data, confidential information or unlawful material", + "short extracts from the cited pages", "You do not copy substantial parts of a source", "responsible for everything your agent submits under your handle"}, + "credits": {"no cash value", "cannot be redeemed, sold or transferred", "count like purchased ones", "do not expire", "awards of the reverted versions", "all awards of that article", + "Royalties are never reclaimed", "never takes a balance below zero", "paused or banned", "A ban blocks future contributions only", "change the amounts of future awards and royalties"}, + "worker-fetching": {"your own machine, your network connection and your IP address", "lawful and permitted by your network", "does not guarantee that any assigned page is lawful or safe to fetch", + "keyed hash of the /24 for IPv4 or of the /48 for IPv6", "at most 7 days", "as long as backups of those records are kept", "does not keep the address", + "second worker or by a server fetch before it is indexed", "rejected without credit", "stop the worker at any time"}, + "takedown": {"remove or correct an article, a contribution or a handle", "takedown contact address shown below and on every article page", "names the article and says why", + "remove the article at once and decide afterwards", "erased, apart from a residue that stops it being recreated", "awards of the article are reclaimed", + "remove any contribution for any reason", "breaks the style guide or these terms"}, + "synth-attribution": {`labelled "Agent submitted"`, "carry the CC BY-SA 4.0 line", "attributed to Cosift, not to a person", `("Contributed by @a, @b") in order of first contribution`, + "stays attributed to Cosift", "gives the attribution shown on its page and shares adaptations under the same licence"}, +} + +func sectionByID(doc *html.Node, id string) *html.Node { + s := find(doc, func(n *html.Node) bool { return n.Data == "section" && attr(n, "id") == id }) + if len(s) != 1 { + return nil + } + return s[0] +} + +func TestTermsPageCarriesTheSixSectionsWithTheirPinnedIDs(t *testing.T) { + h := newHarness(t) + rec := h.get("/wiki/terms") + wantPage(t, rec, 200, public(docMaxAge)) + doc := structure(t, "terms", rec.Body.String()) + if !slices.Equal(TermsSectionIDs, pinnedTermsIDs) || TermsVersion != 1 { + t.Fatalf("exported ids %v, version %d", TermsSectionIDs, TermsVersion) + } + var got []string + for _, s := range find(doc, tag("section")) { + got = append(got, attr(s, "id")) + if len(find(s, tag("h2"))) != 1 { + t.Fatalf("section %s has no single heading", attr(s, "id")) + } + } + if !slices.Equal(got, pinnedTermsIDs) { + t.Fatalf("sections %v", got) + } + toc := find(doc, func(n *html.Node) bool { return n.Data == "a" && strings.HasPrefix(attr(n, "href"), "#") }) + if len(toc) != 6 { + t.Fatalf("%d anchors", len(toc)) + } + for i, a := range toc { + if attr(a, "href") != "#"+pinnedTermsIDs[i] { + t.Fatalf("anchor %d is %q", i, attr(a, "href")) + } + } + if !strings.Contains(textOf(find(doc, tag("h1"))[0]), "version 1") || h.v1.count() != 0 { + t.Fatal("title lacks the version, or the page asked the engine") + } +} + +func TestEverySectionMakesItsRequiredStatements(t *testing.T) { + doc := structure(t, "terms", newHarness(t).get("/wiki/terms").Body.String()) + for _, id := range pinnedTermsIDs { + sec := sectionByID(doc, id) + text := strings.Join(strings.Fields(textOf(sec)), " ") + for _, phrase := range termsStatements[id] { + if !strings.Contains(text, phrase) { + t.Errorf("section %s lacks %q", id, phrase) + } + } + } + if len(termsStatements) != len(pinnedTermsIDs) { + t.Fatal("a section has no statements to check") + } +} + +func TestTakedownSectionShowsTheContactAddress(t *testing.T) { + h := newHarness(t, func(c *Config) { c.ReportMailto = "takedown@example.org" }) + doc := structure(t, "terms", h.get("/wiki/terms").Body.String()) + links := find(sectionByID(doc, "takedown"), func(n *html.Node) bool { return n.Data == "a" }) + if len(links) != 1 || attr(links[0], "href") != "mailto:takedown@example.org" || textOf(links[0]) != "takedown@example.org" { + t.Fatalf("contact links %v", links) + } + for _, id := range pinnedTermsIDs { + if id != "takedown" && len(find(sectionByID(doc, id), tag("a"))) != 0 { + t.Fatalf("section %s carries a link", id) + } + } +} + +func TestTermsWithAnyOtherSectionsAreRefused(t *testing.T) { + good := string(termsMD) + without := func(id string) string { + i := strings.Index(good, "{#"+id+"}") + start := strings.LastIndex(good[:i], "\n## ") + end := strings.Index(good[i:], "\n## ") + if end < 0 { + return good[:start+1] + } + return good[:start+1] + good[i+end+1:] + } + for name, src := range map[string]string{ + "missing section": without("credits"), + "renamed id": strings.Replace(good, "{#worker-fetching}", "{#worker}", 1), + "extra section": good + "\n## Extra {#extra}\n\n- more\n", + "duplicate": good + "\n## Again {#license}\n\n- again\n", + "swapped order": strings.Replace(strings.Replace(good, "{#license}", "{#tmp}", 1), "{#originality}", "{#license}", 1), + "empty section": good[:strings.Index(good, "- Articles written by")], + "no title": strings.TrimPrefix(good, "# Cosift contributor terms (version 1)"), + "heading without id": strings.Replace(good, " {#credits}", "", 1), + } { + if _, _, _, err := parseTerms([]byte(src)); err == nil { + t.Errorf("%s was accepted", name) + } + } + saved := termsMD + defer func() { termsMD = saved }() + termsMD = []byte(without("takedown")) + if _, err := New(testConfig("http://127.0.0.1:1", newClock(), &logBuf{})); err == nil { + t.Fatal("New accepted terms without a required section") + } +} + +func TestTermsAreNotAnArticleSlugOrAMarkdownPage(t *testing.T) { + h := newHarness(t) + h.v1.set("terms", jsonReply(200, published("terms", "Terms"))) + if rec := h.get("/wiki/terms.md"); rec.Code != 404 { + t.Fatalf("terms.md answered %d", rec.Code) + } + if h.v1.slugCalls("terms") != 0 { + t.Fatal("the engine was asked for the reserved slug") + } +} + +func TestGoldenTermsPage(t *testing.T) { + checkGolden(t, "terms.html", newHarness(t).get("/wiki/terms").Body.String()) +} diff --git a/internal/community/wiki/testdata/golden/terms.html b/internal/community/wiki/testdata/golden/terms.html new file mode 100644 index 0000000..559dedb --- /dev/null +++ b/internal/community/wiki/testdata/golden/terms.html @@ -0,0 +1,98 @@ + + + + + +Cosift contributor terms (version 1) · Cosift + + + + + + +
+cosift● + +
+
+
+
+

Cosift contributor terms (version 1)

+

Version 1

+
+
+

These terms apply to everyone who joins as a contributor and submits articles, revisions or fetched pages to Cosift, by hand or through an AI agent. Joining means accepting this version. When a change to the terms matters, the version number rises and you accept the new version before you can submit again.

+ +
+

The grant

+
    +
  • You license every contribution that Cosift publishes, whether a new article, a revision or the text of either, under the Creative Commons Attribution-ShareAlike 4.0 International licence (CC BY-SA 4.0).
  • +
  • The grant is worldwide, non-exclusive and royalty-free. It covers the text you submitted. You keep your copyright. It does not cover the pages your article cites.
  • +
  • Attribution is your current handle, shown as "Contributed by @handle" on the article page and in its .md form, with a link to the article. You can change your handle at any time, and the page shows the current one. If you are banned, the page shows "a community member" in place of your handle. Accounts in local mode are shown as "a community member" too.
  • +
  • Every article page and every .md file carries a CC BY-SA 4.0 line.
  • +
  • You cannot withdraw a published contribution. Cosift removes one only as described under takedown.
+
+
+

The warranty

+
    +
  • You warrant that the submission is your own work, or your agent's output that you have the right to submit. You warrant that it does not infringe anyone's rights and that it contains no personal data, confidential information or unlawful material.
  • +
  • Quotes are short extracts from the cited pages, used to support a claim. You do not copy substantial parts of a source into an article.
  • +
  • You are responsible for everything your agent submits under your handle.
+
+
+

Credits

+
    +
  • Credits have no cash value. They are not property, and they cannot be redeemed, sold or transferred.
  • +
  • Earned credits count like purchased ones in your balance, and they do not expire.
  • +
  • Cosift may reclaim awards. When a revision is reverted, it may reclaim the awards of the reverted versions. When an article is taken down, it may reclaim all awards of that article. Royalties are never reclaimed. A reclaim never takes a balance below zero.
  • +
  • A contributor whose contribution is taken down or reverted can be paused or banned. A ban blocks future contributions only.
  • +
  • Cosift may change the amounts of future awards and royalties.
+
+
+

The worker

+
    +
  • The worker (cosift contribute --worker) fetches the pages that Cosift assigns to it, from your own machine, your network connection and your IP address, and sends the page text to Cosift.
  • +
  • You are responsible for running it where fetching those pages is lawful and permitted by your network. Cosift does not guarantee that any assigned page is lawful or safe to fetch in your place.
  • +
  • Cosift derives a network prefix from your address, which is a keyed hash of the /24 for IPv4 or of the /48 for IPv6, to check that two agreeing workers are on different networks. It keeps that hash for at most 7 days in its working records, and for as long as backups of those records are kept. It does not keep the address.
  • +
  • Fetched content is verified by a second worker or by a server fetch before it is indexed, and it may be rejected without credit.
  • +
  • You can stop the worker at any time.
+
+
+

Takedown

+
    +
  • Anyone can ask Cosift to remove or correct an article, a contribution or a handle by writing to the takedown contact address shown below and on every article page.
  • +
  • The request names the article and says why. Cosift may remove the article at once and decide afterwards. A removed article is erased, apart from a residue that stops it being recreated, and the awards of the article are reclaimed.
  • +
  • Cosift may remove any contribution for any reason, including a contribution that breaks the style guide or these terms.
+

Takedown contact: reports@example.org

+
+
+

Articles Cosift writes itself

+
    +
  • Articles written by Cosift's own AI writer are labelled "Agent submitted" and carry the CC BY-SA 4.0 line. They are attributed to Cosift, not to a person.
  • +
  • When contributors improve an article that Cosift wrote, the article is attributed to the contributors of the improvements ("Contributed by @a, @b") in order of first contribution. The text that Cosift wrote stays attributed to Cosift.
  • +
  • Everyone who reuses an article under CC BY-SA 4.0 gives the attribution shown on its page and shares adaptations under the same licence.
+
+
+
+ +
+ + + diff --git a/internal/community/wiki/wiki.go b/internal/community/wiki/wiki.go index 6f54d7b..3852e12 100644 --- a/internal/community/wiki/wiki.go +++ b/internal/community/wiki/wiki.go @@ -107,7 +107,7 @@ type Wiki struct { logged map[string]time.Time notFound, gone, unavailable, tooMany []byte - style []byte + style, terms []byte } // New builds the page layer; with Public off it is dark and validates nothing. @@ -153,6 +153,9 @@ func New(cfg Config) (*Wiki, error) { w.unavailable = w.renderNotice("Articles are temporarily unavailable", "Please try again in a few minutes.", "") w.tooMany = w.renderNotice("Too many requests", "Please wait a minute and try again.", "") w.style = w.renderStyle() + if w.terms, err = w.renderTerms(); err != nil { + return nil, err + } w.logf("wiki: public pages on (engine %s)", base) return w, nil } @@ -240,6 +243,8 @@ func (w *Wiki) serveWiki(rw http.ResponseWriter, r *http.Request) { switch { case len(parts) == 1 && (parts[0] == "style" || parts[0] == "style.md"): w.serveStyle(rw, r, parts[0] == "style.md") + case len(parts) == 1 && parts[0] == "terms": + w.serveTerms(rw, r) case len(parts) == 1 && pageSlug(parts[0]): w.serveArticle(rw, r, parts[0], false) case len(parts) == 1 && strings.HasSuffix(parts[0], ".md") && pageSlug(strings.TrimSuffix(parts[0], ".md")): From 0eaef55bf0467a336ac1373c1c279587d1b20111 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Wed, 14 Oct 2026 05:52:35 +0300 Subject: [PATCH 016/121] feat(wiki): list wanted stubs by generic title and Low articles at /wiki/wanted --- internal/community/wiki/engine.go | 10 + internal/community/wiki/helpers_test.go | 26 ++- internal/community/wiki/templates/wanted.html | 17 ++ .../wiki/testdata/golden/wanted.html | 43 +++++ internal/community/wiki/wanted.go | 92 +++++++++ internal/community/wiki/wanted_test.go | 178 ++++++++++++++++++ internal/community/wiki/wiki.go | 3 + 7 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 internal/community/wiki/templates/wanted.html create mode 100644 internal/community/wiki/testdata/golden/wanted.html create mode 100644 internal/community/wiki/wanted.go create mode 100644 internal/community/wiki/wanted_test.go diff --git a/internal/community/wiki/engine.go b/internal/community/wiki/engine.go index 87f7328..e9ad965 100644 --- a/internal/community/wiki/engine.go +++ b/internal/community/wiki/engine.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "regexp" + "strconv" "time" ) @@ -253,6 +254,15 @@ func (e *engine) list(ctx context.Context, cursor string) (engineList, string) { } path += "&cursor=" + url.QueryEscape(cursor) } + return e.getList(ctx, path) +} + +// board fetches the newest wantedLimit items of one wanted-board query: pending stubs, or published Low articles. +func (e *engine) board(ctx context.Context, query string) (engineList, string) { + return e.getList(ctx, "/v1/articles?"+query+"&order=updated_at&limit="+strconv.Itoa(wantedLimit)) +} + +func (e *engine) getList(ctx context.Context, path string) (engineList, string) { ctx, cancel := context.WithTimeout(ctx, e.listTimeout) defer cancel() status, body, code := e.get(ctx, path, listLimit) diff --git a/internal/community/wiki/helpers_test.go b/internal/community/wiki/helpers_test.go index d5d8d04..1c6aa34 100644 --- a/internal/community/wiki/helpers_test.go +++ b/internal/community/wiki/helpers_test.go @@ -65,11 +65,12 @@ type fakeV1 struct { slugs map[string]reply items []map[string]any pages map[int]reply + query map[string]reply calls []call } func newFakeV1(t *testing.T) *fakeV1 { - f := &fakeV1{slugs: map[string]reply{}, pages: map[int]reply{}} + f := &fakeV1{slugs: map[string]reply{}, pages: map[int]reply{}, query: map[string]reply{}} f.Server = httptest.NewServer(http.HandlerFunc(f.serve)) t.Cleanup(f.Close) return f @@ -87,7 +88,9 @@ func (f *fakeV1) serve(w http.ResponseWriter, r *http.Request) { rep, ok = errReply(404, "not_found"), true } case r.URL.Path == "/v1/articles": - rep, ok = f.listPage(r.URL.Query().Get("cursor")) + if rep, ok = f.query[r.URL.RawQuery]; !ok { + rep, ok = f.listPage(r.URL.Query().Get("cursor")) + } } f.mu.Unlock() if !ok { @@ -143,6 +146,25 @@ func (f *fakeV1) list(items ...map[string]any) { f.mu.Unlock() } +// setQuery answers GET /v1/articles with exactly this raw query. +func (f *fakeV1) setQuery(raw string, rep reply) { + f.mu.Lock() + f.query[raw] = rep + f.mu.Unlock() +} + +func (f *fakeV1) queryCalls(raw string) int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, c := range f.calls { + if c.path == "/v1/articles" && c.query == raw { + n++ + } + } + return n +} + func (f *fakeV1) failPage(n int, rep reply) { f.mu.Lock() f.pages[n] = rep diff --git a/internal/community/wiki/templates/wanted.html b/internal/community/wiki/templates/wanted.html new file mode 100644 index 0000000..1b748df --- /dev/null +++ b/internal/community/wiki/templates/wanted.html @@ -0,0 +1,17 @@ +{{define "main"}}
+

Wanted articles

+

Cosift has no article on these subjects yet, or only a Low-quality one. Contributors write them with an AI agent, following the style guide and the contributor terms.

+

Subjects with no article yet

+
    +{{range .Stubs}}
  • {{.Title}}{{with .Vertical}} {{.}}{{end}}
  • +{{else}}
  • None right now.
  • +{{end}}
+{{if .MoreStubs}}

Showing the newest {{.Limit}}.

+{{end}}

Low-quality articles that can be improved

+
    +{{range .Thin}}
  • {{.Title}}{{with .Vertical}} {{.}}{{end}}
  • +{{else}}
  • None right now.
  • +{{end}}
+{{if .MoreLow}}

Showing the newest {{.Limit}}.

+{{end}}
+{{end}} diff --git a/internal/community/wiki/testdata/golden/wanted.html b/internal/community/wiki/testdata/golden/wanted.html new file mode 100644 index 0000000..f3b19e7 --- /dev/null +++ b/internal/community/wiki/testdata/golden/wanted.html @@ -0,0 +1,43 @@ + + + + + +Wanted articles · Cosift + + + + + + +
+cosift● + +
+
+
+

Wanted articles

+

Cosift has no article on these subjects yet, or only a Low-quality one. Contributors write them with an AI agent, following the style guide and the contributor terms.

+

Subjects with no article yet

+
    +
  • Tidal power Other
  • +
  • Volcano monitoring Other
  • +
+

Low-quality articles that can be improved

+ +
+ +
+ + + diff --git a/internal/community/wiki/wanted.go b/internal/community/wiki/wanted.go new file mode 100644 index 0000000..5b2bfbf --- /dev/null +++ b/internal/community/wiki/wanted.go @@ -0,0 +1,92 @@ +package wiki + +import ( + "context" + "net/http" + "sync" + "time" +) + +const ( + wantedLimit = 200 + boardBound = hubBound +) + +var wantedTmpl = parse("wanted.html") + +// board is the rendered wanted page and when it was fetched; one engine fetch serves every concurrent miss. +type board struct { + mu sync.Mutex + body []byte + fetched time.Time +} + +type wantedItem struct{ Title, Vertical, Slug string } + +type wantedView struct { + Head head + Stubs, Thin []wantedItem + MoreStubs, MoreLow bool + Limit int +} + +// wantedItems keeps the items that pass the board's own test; the rest are dropped, not shown. +func wantedItems(list engineList, status, tier string) []wantedItem { + var out []wantedItem + for _, it := range list.Items { + title := cleanLine(it.Title) + if it.Status != status || it.QualityTier != tier || !idPattern.MatchString(it.ID) || !pageSlug(it.Slug) || title == "" { + continue + } + v := wantedItem{Title: title, Vertical: verticals[it.Vertical]} + if status == "published" { + v.Slug = it.Slug + } + out = append(out, v) + } + return out +} + +// renderWanted fetches both lists; a stub shows its generic title and vertical only, never an id or a slug. +func (w *Wiki) renderWanted() ([]byte, string) { + ctx := context.Background() + stubs, code := w.engine.board(ctx, "status=pending") + if code != "" { + return nil, code + } + thin, code := w.engine.board(ctx, "status=published&tier=thin") + if code != "" { + return nil, code + } + h := w.head("Wanted articles") + h.Canonical = w.canonical("/wiki/wanted") + h.Description = "Subjects Cosift has no article on yet, and articles that can be improved." + return execute(wantedTmpl, wantedView{Head: h, Stubs: wantedItems(stubs, "pending", ""), Thin: wantedItems(thin, "published", "thin"), + MoreStubs: stubs.NextCursor != nil, MoreLow: thin.NextCursor != nil, Limit: wantedLimit}), "" +} + +func (w *Wiki) serveWanted(rw http.ResponseWriter, r *http.Request) { + body, fetched, ok := w.wantedBoard(rw, r) + if ok { + w.write(rw, http.StatusOK, htmlType, body, maxAge(boardBound, w.now().Sub(fetched))) + } +} + +// wantedBoard returns the cached board, or fetches it for the first miss while the others wait; on false it has answered. +func (w *Wiki) wantedBoard(rw http.ResponseWriter, r *http.Request) ([]byte, time.Time, bool) { + w.board.mu.Lock() + defer w.board.mu.Unlock() + if w.board.body == nil || w.now().Sub(w.board.fetched) >= boardBound { + if !w.charge(rw, r, false) { + return nil, time.Time{}, false + } + body, code := w.renderWanted() + if code != "" { + w.warn(code) + w.unavailableNow(rw) + return nil, time.Time{}, false + } + w.board.body, w.board.fetched = body, w.now() + } + return w.board.body, w.board.fetched, true +} diff --git a/internal/community/wiki/wanted_test.go b/internal/community/wiki/wanted_test.go new file mode 100644 index 0000000..8ac2468 --- /dev/null +++ b/internal/community/wiki/wanted_test.go @@ -0,0 +1,178 @@ +package wiki + +import ( + "fmt" + "strings" + "sync" + "testing" + "time" +) + +const ( + stubQuery = "status=pending&order=updated_at&limit=200" + thinQuery = "status=published&tier=thin&order=updated_at&limit=200" +) + +func stubItem(slug, title string) map[string]any { + return map[string]any{"id": fixtureID(), "slug": slug, "title": title, "vertical": "other", "promoted": false, "status": "pending", "version": 0, + "updated_at": "2026-10-07T08:00:00Z", "readers_7d": 0} +} + +func thinItem(slug, title string) map[string]any { + return map[string]any{"id": fixtureID(), "slug": slug, "title": title, "vertical": "dev-docs", "quality_tier": "thin", "promoted": false, "status": "published", + "version": 2, "updated_at": "2026-10-06T10:00:00Z", "readers_7d": 3} +} + +func boardReply(next any, items ...map[string]any) reply { + if items == nil { + items = []map[string]any{} + } + return jsonReply(200, map[string]any{"items": items, "next_cursor": next}) +} + +func wantedHarness(t *testing.T) *harness { + h := newHarness(t) + h.v1.setQuery(stubQuery, boardReply(nil, stubItem("tidal-power", "Tidal power"), stubItem("volcano-monitoring", "Volcano monitoring"))) + h.v1.setQuery(thinQuery, boardReply(nil, thinItem("rust-async-runtimes", "Rust async runtimes"))) + return h +} + +func TestWantedBoardListsStubsByTitleAndLowArticlesWithALink(t *testing.T) { + h := wantedHarness(t) + rec := h.get("/wiki/wanted") + wantPage(t, rec, 200, public(300)) + doc := structure(t, "wanted", rec.Body.String()) + main := find(doc, tag("main"))[0] + lists := find(main, tag("ul")) + if len(lists) != 2 { + t.Fatalf("%d lists", len(lists)) + } + stubs := find(lists[0], tag("li")) + if len(stubs) != 2 || strings.TrimSpace(textOf(stubs[0])) != "Tidal power Other" || len(find(lists[0], tag("a"))) != 0 { + t.Fatalf("stubs %q", textOf(lists[0])) + } + thin := find(lists[1], tag("a")) + if len(thin) != 1 || attr(thin[0], "href") != "/wiki/rust-async-runtimes" || textOf(thin[0]) != "Rust async runtimes" { + t.Fatal("the Low article is not linked") + } + body := rec.Body.String() + for _, secret := range []string{"tidal-power", "volcano-monitoring", "readers"} { + if strings.Contains(body, secret) { + t.Fatalf("a stub's %q reached the page", secret) + } + } + if h.v1.queryCalls(stubQuery) != 1 || h.v1.queryCalls(thinQuery) != 1 { + t.Fatalf("engine queries %v", h.v1.allCalls()) + } +} + +func TestWantedBoardDropsItemsThatFailTheirListsTest(t *testing.T) { + h := newHarness(t) + published := with(thinItem("not-a-stub", "Published in the stub list"), "status", "published") + badSlug := with(stubItem("Bad_Slug", "Bad slug"), "slug", "Bad_Slug") + noTitle := with(stubItem("no-title", " "), "title", " ‮ ") + h.v1.setQuery(stubQuery, boardReply(nil, published, badSlug, noTitle, stubItem("kept-stub", "Kept stub"))) + strong := with(thinItem("strong-one", "Strong in the thin list"), "quality_tier", "strong") + pending := with(stubItem("pending-one", "Pending in the thin list"), "quality_tier", "thin") + h.v1.setQuery(thinQuery, boardReply(nil, strong, pending, thinItem("kept-thin", "Kept thin"))) + body := h.get("/wiki/wanted").Body.String() + for _, gone := range []string{"Published in the stub list", "Bad slug", "Strong in the thin list", "Pending in the thin list"} { + if strings.Contains(body, gone) { + t.Fatalf("%q was shown", gone) + } + } + for _, kept := range []string{"Kept stub", "Kept thin"} { + if !strings.Contains(body, kept) { + t.Fatalf("%q was dropped", kept) + } + } +} + +func TestWantedBoardEscapesTitlesAndStripsControls(t *testing.T) { + h := newHarness(t) + h.v1.setQuery(stubQuery, boardReply(nil, stubItem("x-one", " ‮RTL​"))) + h.v1.setQuery(thinQuery, boardReply(nil, thinItem("x-two", `">`))) + body := h.get("/wiki/wanted").Body.String() + structure(t, "wanted", body) + if strings.Contains(body, " +
Site banner header
+

` + title + `

` + body + `
+
Copyright 2026 Hexagon Corp. All rights reserved. Cookie settings.
` + } + mux.HandleFunc("/plain.html", func(w http.ResponseWriter, r *http.Request) { + log.add(r) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(page("en", "Hexagons", fixtureArticle))) + }) + mux.HandleFunc("/german.html", func(w http.ResponseWriter, r *http.Request) { + log.add(r) + w.Write([]byte(page("de-AT", "Sechsecke", strings.ReplaceAll(fixtureArticle, "tessellation", "Parkettierung")))) + }) + mux.HandleFunc("/moved", func(w http.ResponseWriter, r *http.Request) { + log.add(r) + http.Redirect(w, r, "/plain.html", http.StatusMovedPermanently) + }) + mux.HandleFunc("/doc.pdf", func(w http.ResponseWriter, r *http.Request) { + log.add(r) + w.Header().Set("Content-Type", "application/pdf") + w.Write(pdf) + }) + mux.HandleFunc("/blocked", func(w http.ResponseWriter, r *http.Request) { + log.add(r) + w.WriteHeader(http.StatusForbidden) + w.Write([]byte("Access denied")) + }) + mux.HandleFunc("/partial", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusPartialContent) + w.Write([]byte(page("en", "Hexagons", fixtureArticle))) + }) + mux.HandleFunc("/image.png", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.Write([]byte("\x89PNG")) + }) + mux.HandleFunc("/empty.html", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte("")) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv, log +} + +type headerLog struct { + mu sync.Mutex + seen []http.Header +} + +func (l *headerLog) add(r *http.Request) { + l.mu.Lock() + l.seen = append(l.seen, r.Header.Clone()) + l.mu.Unlock() +} + +func TestFetchAndExtractEqualsFetchOneOverTheFixtureCorpus(t *testing.T) { + srv, _ := fixtureOrigin(t) + client := originClient(srv) + for _, path := range []string{"/plain.html", "/german.html", "/moved", "/doc.pdf"} { + url := "http://pages.example.com" + path + want, err := crawler.FetchOne(context.Background(), client, "reference/1.0", url, 5<<20) + if err != nil { + t.Fatalf("%s: FetchOne: %v", path, err) + } + got, err := fetchAndExtract(context.Background(), client, crawler.BrowserHeaders(), url, 5<<20) + if err != nil { + t.Fatalf("%s: fetchAndExtract: %v", path, err) + } + if got.Text != want.Text || got.Title != want.Title || got.Lang != want.Lang || got.FinalURL != want.URL { + t.Errorf("%s: worker {%q %q %q %q}, server {%q %q %q %q}", path, got.Text, got.Title, got.Lang, got.FinalURL, want.Text, want.Title, want.Lang, want.URL) + } + sum, ok := crawler.Simhash(want.Text) + if !ok || got.Simhash != crawler.SimhashHex(sum) || got.ContentHash != crawler.ContentHash(want.Text) { + t.Errorf("%s: hashes differ: %s %s", path, got.ContentHash, got.Simhash) + } + if got.Text == "" || len(got.ContentHash) != 64 || len(got.Simhash) != 16 { + t.Errorf("%s: empty or malformed result %+v", path, got) + } + } +} + +func TestFetchAndExtractDropsBoilerplateAndKeepsLanguageAndTheRedirectTarget(t *testing.T) { + srv, _ := fixtureOrigin(t) + client := originClient(srv) + moved, err := fetchAndExtract(context.Background(), client, crawler.BrowserHeaders(), "http://pages.example.com/moved", 0) + if err != nil { + t.Fatal(err) + } + if moved.FinalURL != "http://pages.example.com/plain.html" || moved.Lang != "en" || !strings.Contains(moved.Text, "tessellation") { + t.Fatalf("redirect result: %+v", moved) + } + for _, junk := range []string{"Copyright 2026", "Sign in", "tracking", "ten tips"} { + if strings.Contains(moved.Text, junk) { + t.Errorf("boilerplate %q survived: %q", junk, moved.Text) + } + } + de, err := fetchAndExtract(context.Background(), client, crawler.BrowserHeaders(), "http://pages.example.com/german.html", 0) + if err != nil || de.Lang != "de-AT" { + t.Fatalf("lang: %+v %v", de, err) + } + pdf, err := fetchAndExtract(context.Background(), client, crawler.BrowserHeaders(), "http://pages.example.com/doc.pdf", 0) + if err != nil || !strings.Contains(pdf.Text, "tessellation") { + t.Fatalf("pdf: %+v %v", pdf, err) + } +} + +func TestFetchAndExtractSendsTheBrowserHeaderSet(t *testing.T) { + srv, log := fixtureOrigin(t) + if _, err := fetchAndExtract(context.Background(), originClient(srv), crawler.BrowserHeaders(), "http://pages.example.com/plain.html", 0); err != nil { + t.Fatal(err) + } + if len(log.seen) != 1 { + t.Fatalf("%d requests", len(log.seen)) + } + for name, want := range crawler.BrowserHeaders() { + if got := log.seen[0][name]; len(got) != 1 || got[0] != want[0] { + t.Errorf("%s: got %v, want %v", name, got, want) + } + } +} + +func TestFetchAndExtractReportsNon200AsAStatusAndNothingElseAsOne(t *testing.T) { + srv, _ := fixtureOrigin(t) + client := originClient(srv) + for path, want := range map[string]int{"/blocked": 403, "/partial": 206, "/missing": 404} { + _, err := fetchAndExtract(context.Background(), client, crawler.BrowserHeaders(), "http://pages.example.com"+path, 0) + var status *fetchStatusError + if !errors.As(err, &status) || status.Status != want { + t.Errorf("%s: got %v, want status %d", path, err, want) + } + } + for _, path := range []string{"/image.png", "/empty.html"} { + _, err := fetchAndExtract(context.Background(), client, crawler.BrowserHeaders(), "http://pages.example.com"+path, 0) + var status *fetchStatusError + if err == nil || errors.As(err, &status) { + t.Errorf("%s: want a plain extraction error, got %v", path, err) + } + } +} diff --git a/internal/crawler/fetchone.go b/internal/crawler/fetchone.go index f41e69f..3de05b3 100644 --- a/internal/crawler/fetchone.go +++ b/internal/crawler/fetchone.go @@ -2,6 +2,8 @@ package crawler import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -58,19 +60,35 @@ func FetchOne(ctx context.Context, client *http.Client, userAgent, rawURL string if resp.StatusCode >= 400 { return nil, fmt.Errorf("http %d", resp.StatusCode) } - ct := resp.Header.Get("Content-Type") - if ct != "" && !strings.Contains(ct, "html") && !strings.Contains(ct, "xml") && !strings.Contains(ct, "pdf") { - return nil, fmt.Errorf("non-html/xml/pdf content-type: %s", ct) + if err := checkContentType(resp.Header.Get("Content-Type")); err != nil { + return nil, err } - body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) if err != nil { return nil, err } finalURL := resp.Request.URL.String() - // HTML default; PDF when ct contains "pdf". + parsed, err := ExtractBody(resp.Header.Get("Content-Type"), body, finalURL) + if err != nil { + return nil, err + } + return &FetchResult{ + URL: finalURL, + Title: parsed.Title, + Text: parsed.Text, + Lang: parsed.Lang, + Links: parsed.Links, + }, nil +} + +// ExtractBody turns a fetched body into the parsed page FetchOne returns; binary content types are refused. +func ExtractBody(contentType string, body []byte, finalURL string) (*ParsedDoc, error) { + if err := checkContentType(contentType); err != nil { + return nil, err + } var parsed *ParsedDoc - if strings.Contains(ct, "pdf") { + var err error + if strings.Contains(contentType, "pdf") { parsed, err = ParsePDF(body, finalURL) } else { parsed, err = Parse(body, finalURL) @@ -81,13 +99,20 @@ func FetchOne(ctx context.Context, client *http.Client, userAgent, rawURL string if strings.TrimSpace(parsed.Text) == "" { return nil, errors.New("empty content") } - return &FetchResult{ - URL: finalURL, - Title: parsed.Title, - Text: parsed.Text, - Lang: parsed.Lang, - Links: parsed.Links, - }, nil + return parsed, nil +} + +func checkContentType(ct string) error { + if ct != "" && !strings.Contains(ct, "html") && !strings.Contains(ct, "xml") && !strings.Contains(ct, "pdf") { + return fmt.Errorf("non-html/xml/pdf content-type: %s", ct) + } + return nil +} + +// ContentHash is the lower-case hex SHA-256 of text, the content_hash the server recomputes. +func ContentHash(text string) string { + sum := sha256.Sum256([]byte(text)) + return hex.EncodeToString(sum[:]) } // defaultHTTPClient mirrors the crawler worker's transport tuning. From 8209bc51f1aef74d53108bd7facd0f2c25381d47 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 20:18:28 +0300 Subject: [PATCH 099/121] feat(cosift): -index-locally fetches through fetchAndExtract and the browser client --- cmd/cosift/community_local.go | 4 +-- cmd/cosift/community_local_test.go | 39 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cmd/cosift/community_local.go b/cmd/cosift/community_local.go index d33e6d1..5644724 100644 --- a/cmd/cosift/community_local.go +++ b/cmd/cosift/community_local.go @@ -16,7 +16,7 @@ import ( ) func indexLocalContributions(ctx context.Context, cfg *config.Config, urls []string) ([]*crawler.LocalArtifact, error) { - return indexLocalContributionsWithClient(ctx, cfg, urls, crawler.PublicHTTPClient(30*time.Second)) + return indexLocalContributionsWithClient(ctx, cfg, urls, crawler.BrowserClient(30*time.Second)) } func indexLocalContributionsWithClient(ctx context.Context, cfg *config.Config, urls []string, client *http.Client) ([]*crawler.LocalArtifact, error) { @@ -51,7 +51,7 @@ func indexLocalContributionsWithClient(ctx context.Context, cfg *config.Config, case <-timer.C: } } - page, err := crawler.FetchOne(ctx, client, "Cosift-Community/1.0", raw, 2<<20) + page, err := fetchAndExtract(ctx, client, crawler.BrowserHeaders(), raw, 2<<20) if err != nil { return nil, err } diff --git a/cmd/cosift/community_local_test.go b/cmd/cosift/community_local_test.go index 5d10b25..921e43b 100644 --- a/cmd/cosift/community_local_test.go +++ b/cmd/cosift/community_local_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/pilot-protocol/cosift/internal/config" + "github.com/pilot-protocol/cosift/internal/crawler" "github.com/pilot-protocol/cosift/internal/store" ) @@ -55,3 +56,41 @@ func TestLocalIndexPersistsTextAndEmbeddings(t *testing.T) { t.Fatal("local document was not indexed") } } + +func TestLocalIndexFetchesThroughTheSharedBrowserFetch(t *testing.T) { + emb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Input []string `json:"input"` + } + json.NewDecoder(r.Body).Decode(&body) + data := []any{} + for i := range body.Input { + data = append(data, map[string]any{"index": i, "embedding": []float32{1, 2}}) + } + json.NewEncoder(w).Encode(map[string]any{"data": data}) + })) + defer emb.Close() + cfg := &config.Config{DataDir: t.TempDir(), Embeddings: config.Embeddings{URL: emb.URL, Model: "test", Dim: 2}} + var pageUA string + client := &http.Client{Transport: localPageTransport(func(r *http.Request) (*http.Response, error) { + status, body := 200, `Guide
`+strings.Repeat("This is useful public scientific documentation. ", 5)+`
` + switch r.URL.Path { + case "/robots.txt": + body = "User-agent: *\nAllow: /" + case "/gone": + status = 404 + default: + pageUA = r.Header.Get("User-Agent") + } + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{"text/html"}}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + })} + if _, err := indexLocalContributionsWithClient(context.Background(), cfg, []string{"https://example.com/guide"}, client); err != nil { + t.Fatal(err) + } + if pageUA != crawler.BrowserUserAgent { + t.Fatalf("the page was fetched as %q", pageUA) + } + if _, err := indexLocalContributionsWithClient(context.Background(), cfg, []string{"https://example.com/gone"}, client); err == nil || !strings.Contains(err.Error(), "404") { + t.Fatalf("a 404 page was indexed: %v", err) + } +} From 72dd1abaa0c9a21e8c1ff36c76d09bf8d6959d55 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 20:19:56 +0300 Subject: [PATCH 100/121] feat(cosift): the work API client sends the bearer token and the community header and reports codes only --- cmd/cosift/worker_api.go | 142 ++++++++++++++++++++++++++ cmd/cosift/worker_api_test.go | 109 ++++++++++++++++++++ internal/netguard/httpclients_test.go | 1 + 3 files changed, 252 insertions(+) create mode 100644 cmd/cosift/worker_api.go create mode 100644 cmd/cosift/worker_api_test.go diff --git a/cmd/cosift/worker_api.go b/cmd/cosift/worker_api.go new file mode 100644 index 0000000..7956076 --- /dev/null +++ b/cmd/cosift/worker_api.go @@ -0,0 +1,142 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const workReplyLimit = 1 << 20 + +type workLease struct { + LeaseID string `json:"lease_id"` + URL string `json:"url"` + Source string `json:"source"` + ExpiresAt string `json:"expires_at"` + ExtractVersion int `json:"extract_version"` +} + +type claimReply struct { + Leases []workLease `json:"leases"` + RetryAfterS int `json:"retry_after_s"` + Ceiling bool `json:"moderation_ceiling"` +} + +type submitReply struct { + Verdict string `json:"verdict"` + AgreeingCount int `json:"agreeing_count"` + Indexed bool `json:"indexed"` + Reason string `json:"reason"` + CreditsEarned *int `json:"credits_earned"` +} + +// workAPIError is a refusal from the community app's work routes; it carries codes only, never the response text. +type workAPIError struct { + Status int + Code string + Field, Rule string + RetryAfter time.Duration + ExtractVersion int +} + +func (e *workAPIError) Error() string { + msg := fmt.Sprintf("HTTP %d %s", e.Status, e.Code) + if e.Field != "" { + msg += " (" + e.Field + ": " + e.Rule + ")" + } + return msg +} + +// workAPI calls /api/work/* with the bearer token; the token appears nowhere else. +type workAPI struct { + base string + token string + client *http.Client +} + +func newWorkAPI(base, token string) *workAPI { + return &workAPI{ + base: strings.TrimRight(base, "/"), + token: token, + client: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, + } +} + +func (a *workAPI) claim(ctx context.Context, max int) (claimReply, error) { + var out claimReply + return out, a.post(ctx, "claim", map[string]int{"max": max}, &out) +} + +func (a *workAPI) submit(ctx context.Context, body map[string]any) (submitReply, error) { + var out submitReply + return out, a.post(ctx, "submit", body, &out) +} + +func (a *workAPI) post(ctx context.Context, route string, body, out any) error { + raw, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.base+"/api/work/"+route, bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Cosift-Client", "community") + req.Header.Set("Authorization", "Bearer "+a.token) + res, err := a.client.Do(req) + if err != nil { + return fmt.Errorf("work %s: %w", route, err) + } + defer res.Body.Close() + data, err := io.ReadAll(io.LimitReader(res.Body, workReplyLimit)) + if err != nil { + return fmt.Errorf("work %s: %w", route, err) + } + if res.StatusCode != http.StatusOK { + return parseWorkError(res, data) + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("work %s: unreadable answer", route) + } + return nil +} + +func parseWorkError(res *http.Response, data []byte) *workAPIError { + var e struct { + Code string `json:"code"` + Field string `json:"field"` + Rule string `json:"rule"` + RetryAfterSecs int `json:"retry_after_seconds"` + ExtractVersion int `json:"extract_version"` + } + _ = json.Unmarshal(data, &e) + out := &workAPIError{Status: res.StatusCode, Code: safeToken(e.Code), Field: safeToken(e.Field), Rule: safeToken(e.Rule), ExtractVersion: e.ExtractVersion} + secs := e.RetryAfterSecs + if h, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && h > 0 { + secs = h + } + if secs > 0 { + out.RetryAfter = time.Duration(secs) * time.Second + } + return out +} + +// safeToken keeps a server-supplied code printable: lower-case words only, short. +func safeToken(s string) string { + if len(s) == 0 || len(s) > 40 { + return "" + } + for _, c := range s { + if !(c >= 'a' && c <= 'z' || c == '_') { + return "" + } + } + return s +} diff --git a/cmd/cosift/worker_api_test.go b/cmd/cosift/worker_api_test.go new file mode 100644 index 0000000..84789cf --- /dev/null +++ b/cmd/cosift/worker_api_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +const workerTestToken = "ck_1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + +type apiRecorder struct { + mu sync.Mutex + calls []recordedCall +} + +type recordedCall struct { + path string + header http.Header + body map[string]any +} + +func (r *apiRecorder) handler(reply func(call recordedCall) (int, http.Header, string)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + raw, _ := io.ReadAll(req.Body) + call := recordedCall{path: req.URL.Path, header: req.Header.Clone()} + json.Unmarshal(raw, &call.body) + r.mu.Lock() + r.calls = append(r.calls, call) + r.mu.Unlock() + status, header, body := reply(call) + for k, v := range header { + w.Header()[k] = v + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + io.WriteString(w, body) + }) +} + +func TestWorkAPISendsTheBearerTokenAndTheCommunityHeaderWithoutAnOrigin(t *testing.T) { + rec := &apiRecorder{} + srv := httptest.NewServer(rec.handler(func(recordedCall) (int, http.Header, string) { + return 200, nil, `{"leases":[{"lease_id":"c1","url":"https://example.org/a","source":"errored","expires_at":"2026-10-20T10:10:00Z","extract_version":1}],"moderation_ceiling":false}` + })) + defer srv.Close() + reply, err := newWorkAPI(srv.URL+"/", workerTestToken).claim(context.Background(), 3) + if err != nil || len(reply.Leases) != 1 || reply.Leases[0].LeaseID != "c1" { + t.Fatalf("%+v %v", reply, err) + } + c := rec.calls[0] + if c.path != "/api/work/claim" || c.body["max"] != float64(3) { + t.Errorf("request: %s %v", c.path, c.body) + } + if c.header.Get("Authorization") != "Bearer "+workerTestToken || c.header.Get("X-Cosift-Client") != "community" || c.header.Get("Content-Type") != "application/json" { + t.Errorf("headers: %v", c.header) + } + if _, has := c.header["Origin"]; has { + t.Error("an Origin header was sent") + } + if _, has := c.header["Cookie"]; has { + t.Error("a cookie was sent") + } +} + +func TestWorkAPIErrorsCarryCodesAndRetryHintsAndNeverTheBodyOrToken(t *testing.T) { + rec := &apiRecorder{} + srv := httptest.NewServer(rec.handler(func(c recordedCall) (int, http.Header, string) { + return 429, http.Header{"Retry-After": {"17"}}, `{"error":"echo ` + c.header.Get("Authorization") + `","code":"rate_limited","retry_after_seconds":3}` + })) + defer srv.Close() + _, err := newWorkAPI(srv.URL, workerTestToken).claim(context.Background(), 1) + var apiErr *workAPIError + if !errors.As(err, &apiErr) || apiErr.Status != 429 || apiErr.Code != "rate_limited" || apiErr.RetryAfter != 17*time.Second { + t.Fatalf("got %#v", err) + } + if strings.Contains(err.Error(), "AAAA") || strings.Contains(err.Error(), "echo") { + t.Fatalf("error text leaks: %q", err) + } + for _, body := range []string{`{"code":"Bad Code!"}`, `{"code":"` + strings.Repeat("a", 60) + `"}`} { + srv2 := httptest.NewServer(rec.handler(func(recordedCall) (int, http.Header, string) { return 500, nil, body })) + _, err := newWorkAPI(srv2.URL, workerTestToken).claim(context.Background(), 1) + srv2.Close() + if !errors.As(err, &apiErr) || apiErr.Code != "" { + t.Errorf("an unsafe code was kept: %#v", apiErr) + } + } +} + +func TestWorkAPIDoesNotFollowRedirects(t *testing.T) { + var hits int + other := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hits++ })) + defer other.Close() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, other.URL, http.StatusTemporaryRedirect) + })) + defer srv.Close() + _, err := newWorkAPI(srv.URL, workerTestToken).claim(context.Background(), 1) + var apiErr *workAPIError + if !errors.As(err, &apiErr) || apiErr.Status != 307 || hits != 0 { + t.Fatalf("err %v, redirect target hit %d times", err, hits) + } +} diff --git a/internal/netguard/httpclients_test.go b/internal/netguard/httpclients_test.go index 7196621..e787fb4 100644 --- a/internal/netguard/httpclients_test.go +++ b/internal/netguard/httpclients_test.go @@ -21,6 +21,7 @@ var ( var unguardedByDesign = map[string]string{ "internal/sharedaccount/remote.go": "operator-configured auth/MCP origins only; HTTPS except loopback; redirects disabled; never accepts URLs from user input", "cmd/cosift/community.go": "CLI talks only to its explicitly configured server; redirects disabled", + "cmd/cosift/worker_api.go": "worker CLI talks only to its explicitly configured server; HTTPS or loopback; redirects disabled", "internal/community/server.go": "operator-configured backend origin only; redirects disabled; contributed URLs use crawler.PublicHTTPClient", "internal/community/engine.go": "the engine /v1 listener on a loopback origin validated at start; the broker key goes nowhere else; redirects disabled; never dials a URL from a request", "internal/community/wiki/engine.go": "the engine /v1 listener on a loopback IP literal validated at start; no proxy; redirects disabled; never dials a URL from a request", From e7cc0f4f8bd3ad180bfef54cfd6dd803abfeb2c9 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 20:25:52 +0300 Subject: [PATCH 101/121] feat(cosift): a work submission is built from the extracted page and refused locally when the server would refuse it --- cmd/cosift/worker_submit.go | 56 ++++++++++++++++++++++++++++++++ cmd/cosift/worker_submit_test.go | 49 ++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 cmd/cosift/worker_submit.go create mode 100644 cmd/cosift/worker_submit_test.go diff --git a/cmd/cosift/worker_submit.go b/cmd/cosift/worker_submit.go new file mode 100644 index 0000000..dfbd709 --- /dev/null +++ b/cmd/cosift/worker_submit.go @@ -0,0 +1,56 @@ +package main + +import ( + "net/http" + "regexp" + "strings" + "unicode/utf8" + + "github.com/pilot-protocol/cosift/internal/crawler" +) + +const ( + minTextBytes = 80 + maxTextBytes = 512 << 10 + maxTitleBytes = 1000 + maxLangBytes = 35 +) + +var langTag = regexp.MustCompile(`^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$`) + +// clampTitle cuts a title to the server's byte bound on a character boundary. +func clampTitle(s string) string { + if !utf8.ValidString(s) { + s = strings.ToValidUTF8(s, "") + } + if len(s) <= maxTitleBytes { + return s + } + s = s[:maxTitleBytes] + for !utf8.ValidString(s) { + s = s[:len(s)-1] + } + return s +} + +// submissionFor builds the attestation for a lease, or says why the page cannot be submitted. +func submissionFor(leaseID string, p *extracted) (map[string]any, string) { + switch { + case len(p.Text) < minTextBytes: + return nil, "too_short" + case len(p.Text) > maxTextBytes: + return nil, "too_long" + case !utf8.ValidString(p.Text) || strings.ContainsRune(p.Text, 0): + return nil, "bad_text" + case len(p.FinalURL) > 2048: + return nil, "bad_url" + } + lang := p.Lang + if len(lang) > maxLangBytes || !langTag.MatchString(lang) { + lang = "" + } + return map[string]any{ + "lease_id": leaseID, "final_url": p.FinalURL, "http_status": http.StatusOK, "title": clampTitle(p.Title), + "text": p.Text, "lang": lang, "content_hash": p.ContentHash, "simhash": p.Simhash, "extract_version": crawler.ExtractVersion, + }, "" +} diff --git a/cmd/cosift/worker_submit_test.go b/cmd/cosift/worker_submit_test.go new file mode 100644 index 0000000..c0b4964 --- /dev/null +++ b/cmd/cosift/worker_submit_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestClampTitleCutsOnACharacterBoundary(t *testing.T) { + long := "a" + strings.Repeat("é", 600) + got := clampTitle(long) + if len(got) != 999 || !utf8.ValidString(got) || !strings.HasPrefix(long, got) { + t.Fatalf("len %d", len(got)) + } + if clampTitle("short") != "short" { + t.Fatal("a short title changed") + } +} + +func TestWorkerSubmissionBoundsAreCheckedBeforeSending(t *testing.T) { + good := &extracted{FinalURL: "http://a.example.com/x", Title: "T", Text: strings.Repeat("word ", 40), Lang: "en", ContentHash: "h", Simhash: "s"} + if body, why := submissionFor("l", good); why != "" || body["lang"] != "en" { + t.Fatalf("%v %q", body, why) + } + for name, mutate := range map[string]func(*extracted){ + "short": func(e *extracted) { e.Text = "tiny" }, + "large": func(e *extracted) { e.Text = strings.Repeat("x", 512<<10+1) }, + "nul": func(e *extracted) { e.Text = strings.Repeat("word ", 40) + "\x00" }, + "utf8": func(e *extracted) { e.Text = strings.Repeat("word ", 40) + "\xff" }, + "url": func(e *extracted) { e.FinalURL = "http://a.example.com/" + strings.Repeat("x", 2048) }, + } { + e := *good + mutate(&e) + if _, why := submissionFor("l", &e); why == "" { + t.Errorf("%s was accepted", name) + } + } + e := *good + for _, lang := range []string{"not a tag!", "en-US-u-co-phonebk-ca-gregory-nu-latn"} { + e.Lang = lang + if body, why := submissionFor("l", &e); why != "" || body["lang"] != "" { + t.Errorf("language tag %q: sent %v, refused %q", lang, body["lang"], why) + } + } + e.Lang = "en-US-u-co-phonebk-ca-gregory-nuxx" + if body, _ := submissionFor("l", &e); body["lang"] != e.Lang { + t.Errorf("a 35-byte tag was blanked: %v", body["lang"]) + } +} From 96bbf4a2bbc3b0aee52c2d4c51b0ce63e6b2707c Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 20:25:52 +0300 Subject: [PATCH 102/121] feat(cosift): classify work refusals into retry, back-off, stop-with-guidance and skip --- cmd/cosift/worker_classify_test.go | 63 +++++++++++++++++++++ cmd/cosift/worker_retry.go | 90 ++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 cmd/cosift/worker_classify_test.go create mode 100644 cmd/cosift/worker_retry.go diff --git a/cmd/cosift/worker_classify_test.go b/cmd/cosift/worker_classify_test.go new file mode 100644 index 0000000..9812a2e --- /dev/null +++ b/cmd/cosift/worker_classify_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "errors" + "fmt" + "testing" + "time" +) + +func TestBackoffDoublesToACap(t *testing.T) { + want := []time.Duration{time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second, 30 * time.Second, 30 * time.Second} + for i, w := range want { + if got := backoff(i); got != w { + t.Errorf("backoff(%d) = %v, want %v", i, got, w) + } + } + if backoff(1000) != 30*time.Second { + t.Error("the cap does not hold") + } +} + +// blockingOrigin holds every request until release is closed; gate receives one value per request that arrived. + +func TestClassifyWorkErrTable(t *testing.T) { + api := func(status int, code, field, rule string, retry time.Duration) error { + return fmt.Errorf("wrapped: %w", &workAPIError{Status: status, Code: code, Field: field, Rule: rule, RetryAfter: retry}) + } + for name, tc := range map[string]struct { + err error + act action + reason string + fail bool + wait time.Duration + }{ + "transport": {errors.New("connection refused"), actBackoff, "", false, 0}, + "503": {api(503, "engine_unavailable", "", "", 0), actBackoff, "", false, 0}, + "502": {api(502, "", "", "", 0), actBackoff, "", false, 0}, + "rate limited": {api(429, "rate_limited", "", "", 9*time.Second), actBackoff, "", false, 9 * time.Second}, + "rate limit floor": {api(429, "rate_limited", "", "", 0), actBackoff, "", false, time.Second}, + "rate limit cap": {api(429, "rate_limited", "", "", time.Hour), actBackoff, "", false, 2 * time.Minute}, + "daily cap": {api(429, "daily_cap", "", "", time.Hour), actStop, "daily_cap", false, time.Hour}, + "lease expired": {api(409, "lease_expired", "", "", 0), actRetryLease, "", false, 0}, + "lease not owner": {api(409, "lease_not_owner", "", "", 0), actRetryLease, "", false, 0}, + "version": {api(409, "extract_version_mismatch", "", "", 0), actStop, "upgrade_required", true, 0}, + "hash mismatch": {api(422, "invalid_field", "content_hash", "mismatch", 0), actStop, "upgrade_required", true, 0}, + "simhash mismatch": {api(422, "invalid_field", "simhash", "mismatch", 0), actStop, "upgrade_required", true, 0}, + "off host": {api(422, "invalid_field", "final_url", "off_host", 0), actSkip, "invalid_field", false, 0}, + "mismatch elsewhere": {api(422, "invalid_field", "title", "mismatch", 0), actSkip, "invalid_field", false, 0}, + "not joined": {api(403, "not_joined", "", "", 0), actStop, "not_joined", true, 0}, + "banned": {api(403, "banned", "", "", 0), actStop, "banned", true, 0}, + "terms": {api(403, "terms_outdated", "", "", 0), actStop, "terms_outdated", true, 0}, + "other 403": {api(403, "", "", "", 0), actStop, "forbidden", true, 0}, + "unauthorized": {api(401, "bearer_required", "", "", 0), actStop, "unauthorized", true, 0}, + "not found": {api(404, "not_found", "", "", 0), actStop, "not_available", true, 0}, + "bad body": {api(400, "invalid_body", "", "", 0), actSkip, "invalid_body", false, 0}, + "too large": {api(413, "body_too_large", "", "", 0), actSkip, "body_too_large", false, 0}, + } { + d := classifyWorkErr(tc.err) + if d.act != tc.act || d.reason != tc.reason || d.fail != tc.fail || d.wait != tc.wait { + t.Errorf("%s: got %+v", name, d) + } + } +} diff --git a/cmd/cosift/worker_retry.go b/cmd/cosift/worker_retry.go new file mode 100644 index 0000000..7d1d4fb --- /dev/null +++ b/cmd/cosift/worker_retry.go @@ -0,0 +1,90 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/pilot-protocol/cosift/internal/crawler" +) + +const ( + maxTries = 5 + backoffBase = time.Second + backoffCap = 30 * time.Second + retryAfterCap = 2 * time.Minute + leaseRetries = 2 +) + +func backoff(n int) time.Duration { + d := backoffBase + for i := 0; i < n && d < backoffCap; i++ { + d *= 2 + } + return min(d, backoffCap) +} + +type action int + +const ( + actBackoff action = iota + actRetryLease + actStop + actSkip +) + +// decision is what to do about a refusal: wait and retry, retry a lost lease, stop the run, or skip the lease. +type decision struct { + act action + wait time.Duration + reason string + msg string + fail bool +} + +const upgradeAdvice = "upgrade cosift (re-run the installer) and try again" + +func upgradeStop(server int) decision { + msg := fmt.Sprintf("this cosift extracts pages as version %d", crawler.ExtractVersion) + if server > 0 { + msg += fmt.Sprintf(" and the server expects version %d", server) + } + return decision{act: actStop, reason: "upgrade_required", fail: true, msg: msg + ": " + upgradeAdvice} +} + +func classifyWorkErr(err error) decision { + var e *workAPIError + if !errors.As(err, &e) { + return decision{act: actBackoff} + } + switch { + case e.Status == http.StatusUnauthorized: + return decision{act: actStop, reason: "unauthorized", fail: true, msg: "the server refused the bearer token (HTTP 401); worker mode needs the ck_ token from cosift-install in COSIFT_TOKEN or a saved session, and a cookie-only session is refused"} + case e.Status == http.StatusForbidden: + switch e.Code { + case "not_joined": + return decision{act: actStop, reason: e.Code, fail: true, msg: "this account has not joined the contributor network; join first, then run the worker again"} + case "banned": + return decision{act: actStop, reason: e.Code, fail: true, msg: "this account is suspended from contributing"} + case "terms_outdated": + return decision{act: actStop, reason: e.Code, fail: true, msg: "accept the current contributor terms, then run the worker again"} + } + return decision{act: actStop, reason: "forbidden", fail: true, msg: "the server refused this account (HTTP 403 " + e.Code + ")"} + case e.Status == http.StatusNotFound: + return decision{act: actStop, reason: "not_available", fail: true, msg: "this server does not offer worker mode"} + case e.Status == http.StatusConflict && (e.Code == "lease_expired" || e.Code == "lease_not_owner"): + return decision{act: actRetryLease} + case e.Status == http.StatusConflict && e.Code == "extract_version_mismatch": + return upgradeStop(e.ExtractVersion) + case e.Status == http.StatusUnprocessableEntity && e.Rule == "mismatch" && (e.Field == "content_hash" || e.Field == "simhash"): + return upgradeStop(0) + case e.Status == http.StatusTooManyRequests && e.Code == "daily_cap": + return decision{act: actStop, reason: "daily_cap", wait: e.RetryAfter} + case e.Status == http.StatusTooManyRequests: + return decision{act: actBackoff, wait: min(max(e.RetryAfter, time.Second), retryAfterCap)} + case e.Status >= 500: + return decision{act: actBackoff} + } + return decision{act: actSkip, reason: e.Code} +} From be2a5db27ed0bbe009e45d077edf97e93da9ade1 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 20:25:52 +0300 Subject: [PATCH 103/121] feat(cosift): the bounded worker loop claims, fetches and submits with progress as JSON lines --- cmd/cosift/fetch_extract_test.go | 4 + cmd/cosift/worker.go | 443 ++++++++++++++++++++++++ cmd/cosift/worker_bounds_test.go | 136 ++++++++ cmd/cosift/worker_claim_refusal_test.go | 28 ++ cmd/cosift/worker_flow_test.go | 104 ++++++ cmd/cosift/worker_interrupt_test.go | 66 ++++ cmd/cosift/worker_rig_test.go | 206 +++++++++++ 7 files changed, 987 insertions(+) create mode 100644 cmd/cosift/worker.go create mode 100644 cmd/cosift/worker_bounds_test.go create mode 100644 cmd/cosift/worker_claim_refusal_test.go create mode 100644 cmd/cosift/worker_flow_test.go create mode 100644 cmd/cosift/worker_interrupt_test.go create mode 100644 cmd/cosift/worker_rig_test.go diff --git a/cmd/cosift/fetch_extract_test.go b/cmd/cosift/fetch_extract_test.go index 0724604..0d3e683 100644 --- a/cmd/cosift/fetch_extract_test.go +++ b/cmd/cosift/fetch_extract_test.go @@ -65,6 +65,10 @@ func fixtureOrigin(t *testing.T) (*httptest.Server, *headerLog) { w.WriteHeader(http.StatusForbidden) w.Write([]byte("Access denied")) }) + mux.HandleFunc("/to-private", func(w http.ResponseWriter, r *http.Request) { + log.add(r) + http.Redirect(w, r, "http://127.0.0.1/secret", http.StatusFound) + }) mux.HandleFunc("/partial", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusPartialContent) diff --git a/cmd/cosift/worker.go b/cmd/cosift/worker.go new file mode 100644 index 0000000..79c34bc --- /dev/null +++ b/cmd/cosift/worker.go @@ -0,0 +1,443 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/pilot-protocol/cosift/internal/crawler" +) + +const ( + maxClaim = 32 + idleWait = 30 * time.Second + idleWaitCap = 5 * time.Minute + idleLimit = 10 + defaultGrace = 10 * time.Second + workerBody = 5 << 20 +) + +type workerOptions struct { + MaxPages int + Duration time.Duration + Concurrency int +} + +type workerSummary struct { + Claimed, Submitted, Reported, Skipped, Abandoned, LeaseLost int + Credits int + Verdicts map[string]int + StopReason string +} + +type worker struct { + api *workAPI + fetch *http.Client + header http.Header + opts workerOptions + out io.Writer + errOut io.Writer + grace time.Duration + idleLimit int + sleep func(context.Context, time.Duration) error + now func() time.Time + + mu sync.Mutex + sum workerSummary + inflight int + stopMsg string + stopFail bool + slotFreed chan struct{} + fatal chan struct{} + fatalOnce sync.Once + stopClaim context.CancelFunc + started time.Time +} + +func newWorker(api *workAPI, fetch *http.Client, opts workerOptions, out, errOut io.Writer) *worker { + opts.Concurrency = max(opts.Concurrency, 1) + return &worker{ + api: api, fetch: fetch, header: crawler.BrowserHeaders(), opts: opts, out: out, errOut: errOut, + grace: defaultGrace, idleLimit: idleLimit, sleep: sleepContext, now: time.Now, + slotFreed: make(chan struct{}, 1), fatal: make(chan struct{}), + sum: workerSummary{Verdicts: map[string]int{}}, + } +} + +func sleepContext(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +func (w *worker) summary() workerSummary { + w.mu.Lock() + defer w.mu.Unlock() + s := w.sum + s.Verdicts = map[string]int{} + for k, v := range w.sum.Verdicts { + s.Verdicts[k] = v + } + return s +} + +func (w *worker) emit(event string, fields map[string]any) { + line := map[string]any{"event": event} + for k, v := range fields { + line[k] = v + } + raw, _ := json.Marshal(line) + w.mu.Lock() + defer w.mu.Unlock() + w.out.Write(append(raw, '\n')) +} + +type outcome int + +const ( + done outcome = iota + finalDecision + exhausted + lost + interrupted +) + +// call runs fn until it succeeds or a refusal is final; transient failures back off and lease losses are retried a bounded number of times. +func (w *worker) call(ctx context.Context, fn func() error) (decision, outcome) { + backoffs, leaseTries := 0, 0 + for tries := 1; ; tries++ { + err := fn() + if err == nil { + return decision{}, done + } + if ctx.Err() != nil { + return decision{}, interrupted + } + d := classifyWorkErr(err) + var wait time.Duration + switch d.act { + case actBackoff: + if tries >= maxTries { + var e *workAPIError + if errors.As(err, &e) { + d.msg = fmt.Sprintf("the server stayed unavailable (%s) after %d tries", e.Error(), maxTries) + } else { + d.msg = fmt.Sprintf("the server could not be reached after %d tries", maxTries) + } + d.reason, d.fail = "unavailable", true + return d, exhausted + } + if wait = d.wait; wait == 0 { + wait = backoff(backoffs) + backoffs++ + } + w.emit("waiting", map[string]any{"seconds": wait.Seconds()}) + case actRetryLease: + if leaseTries >= leaseRetries { + return d, lost + } + wait = backoff(leaseTries) + leaseTries++ + default: + return d, finalDecision + } + if w.sleep(ctx, wait) != nil { + return decision{}, interrupted + } + } +} + +func (w *worker) stop(d decision) { + if d.reason == "daily_cap" { + d.msg = "the daily fetch credit cap is reached" + if d.wait > 0 { + d.msg += "; it resets at " + w.now().Add(d.wait).UTC().Format(time.RFC3339) + } + } + w.mu.Lock() + apply := w.sum.StopReason == "" || (d.fail && !w.stopFail) + if apply { + w.sum.StopReason, w.stopMsg, w.stopFail = d.reason, d.msg, d.fail + } + w.mu.Unlock() + if !apply { + return + } + if d.msg != "" && !d.fail { + fmt.Fprintln(w.errOut, d.msg) + } + fields := map[string]any{"reason": d.reason} + if d.msg != "" { + fields["message"] = d.msg + } + w.emit("stopped", fields) + w.stopClaim() + if d.fail { + w.fatalOnce.Do(func() { close(w.fatal) }) + } +} + +func (w *worker) run(ctx context.Context) error { + if w.opts.MaxPages <= 0 && w.opts.Duration <= 0 { + return errors.New("set -max-pages or -duration so the worker stops") + } + w.started = w.now() + limitCtx, cancelLimit := ctx, context.CancelFunc(func() {}) + if w.opts.Duration > 0 { + limitCtx, cancelLimit = context.WithTimeout(ctx, w.opts.Duration) + } + defer cancelLimit() + claimCtx, stopClaim := context.WithCancel(limitCtx) + defer stopClaim() + w.stopClaim = stopClaim + opCtx, opCancel := context.WithCancel(context.WithoutCancel(ctx)) + defer opCancel() + finished := make(chan struct{}) + go func() { + select { + case <-limitCtx.Done(): + case <-w.fatal: + case <-finished: + return + } + t := time.NewTimer(w.grace) + defer t.Stop() + select { + case <-t.C: + opCancel() + case <-finished: + } + }() + + var wg sync.WaitGroup + w.claimLoop(claimCtx, opCtx, &wg) + wg.Wait() + close(finished) + + w.mu.Lock() + if w.sum.StopReason == "" { + if ctx.Err() != nil { + w.sum.StopReason = "interrupted" + } else { + w.sum.StopReason = "duration" + } + } + reason, msg, fail := w.sum.StopReason, w.stopMsg, w.stopFail + w.mu.Unlock() + s := w.summary() + line := map[string]any{"claimed": s.Claimed, "submitted": s.Submitted, "reported": s.Reported, "skipped": s.Skipped, "abandoned": s.Abandoned, "lease_lost": s.LeaseLost, "verdicts": s.Verdicts, "elapsed_s": int(w.now().Sub(w.started).Seconds()), "stop_reason": reason} + if s.Credits > 0 { + line["credits_earned"] = s.Credits + } + w.emit("summary", line) + if fail { + return errors.New(msg) + } + return nil +} + +// room blocks until a lease slot is free and returns how many leases to ask for, or 0 when the page budget is spent or claiming has stopped. +func (w *worker) room(ctx context.Context) int { + for { + w.mu.Lock() + free := w.opts.Concurrency - w.inflight + budget := maxClaim + if w.opts.MaxPages > 0 { + budget = w.opts.MaxPages - w.sum.Claimed + } + w.mu.Unlock() + if budget <= 0 { + w.stop(decision{reason: "max_pages"}) + return 0 + } + if free > 0 { + return min(maxClaim, free, budget) + } + select { + case <-w.slotFreed: + case <-ctx.Done(): + return 0 + } + } +} + +func (w *worker) claimLoop(claimCtx, opCtx context.Context, wg *sync.WaitGroup) { + idle := 0 + for claimCtx.Err() == nil { + want := w.room(claimCtx) + if want == 0 { + return + } + var reply claimReply + var refusal error + d, res := w.call(claimCtx, func() (err error) { + reply, err = w.api.claim(claimCtx, want) + refusal = err + return err + }) + switch res { + case interrupted: + return + case finalDecision, exhausted: + if d.act == actStop || res == exhausted { + w.stop(d) + return + } + w.stop(decision{reason: "claim_refused", fail: true, msg: fmt.Sprintf("the server refused the claim (%s)", refusal)}) + return + } + leases := reply.Leases + if len(leases) > want { + leases = leases[:want] + } + if len(leases) == 0 { + if reply.Ceiling { + w.stop(decision{reason: "moderation_ceiling", msg: "today's moderation ceiling is reached; try again after 00:00 UTC"}) + return + } + idle++ + if idle >= w.idleLimit { + w.stop(decision{reason: "no_work", msg: "no work is available right now; try again later"}) + return + } + wait := idleWait + if reply.RetryAfterS > 0 { + wait = min(time.Duration(reply.RetryAfterS)*time.Second, idleWaitCap) + } + w.emit("waiting", map[string]any{"seconds": wait.Seconds(), "reason": "no_work"}) + if w.sleep(claimCtx, wait) != nil { + return + } + continue + } + idle = 0 + w.mu.Lock() + w.inflight += len(leases) + w.sum.Claimed += len(leases) + w.mu.Unlock() + w.emit("claimed", map[string]any{"count": len(leases)}) + for _, l := range leases { + wg.Add(1) + go func() { + defer wg.Done() + defer w.release() + w.process(opCtx, l) + }() + } + } +} + +func (w *worker) release() { + w.mu.Lock() + w.inflight-- + w.mu.Unlock() + select { + case w.slotFreed <- struct{}{}: + default: + } +} + +func (w *worker) count(field *int) { + w.mu.Lock() + *field++ + w.mu.Unlock() +} + +func (w *worker) skip(reason string) { + w.count(&w.sum.Skipped) + w.emit("skipped", map[string]any{"reason": reason}) +} + +func (w *worker) abandon() { + w.count(&w.sum.Abandoned) + w.emit("abandoned", nil) +} + +func (w *worker) process(ctx context.Context, l workLease) { + if l.ExtractVersion != crawler.ExtractVersion { + w.count(&w.sum.Abandoned) + w.stop(upgradeStop(l.ExtractVersion)) + return + } + u, err := url.Parse(l.URL) + if err != nil || crawler.CheckPublicTarget(u) != nil { + w.skip("lease_url") + return + } + page, err := fetchAndExtract(ctx, w.fetch, w.header, l.URL, workerBody) + var status *fetchStatusError + switch { + case ctx.Err() != nil: + w.abandon() + return + case errors.As(err, &status): + w.send(ctx, map[string]any{"lease_id": l.LeaseID, "http_status": status.Status}, status.Status) + return + case err != nil: + w.skip("fetch_error") + return + } + body, why := submissionFor(l.LeaseID, page) + if why != "" { + w.skip(why) + return + } + w.send(ctx, body, http.StatusOK) +} + +// send submits a body; httpStatus is the status the page answered, 200 for an attestation. +func (w *worker) send(ctx context.Context, body map[string]any, httpStatus int) { + var reply submitReply + d, res := w.call(ctx, func() (err error) { + reply, err = w.api.submit(ctx, body) + return err + }) + switch res { + case interrupted: + w.abandon() + case lost: + w.count(&w.sum.LeaseLost) + w.emit("lease_lost", nil) + case exhausted: + w.abandon() + case finalDecision: + if d.act == actStop { + w.count(&w.sum.Abandoned) + w.stop(d) + return + } + w.skip(d.reason) + case done: + if httpStatus != http.StatusOK { + w.count(&w.sum.Reported) + w.emit("reported", map[string]any{"http_status": httpStatus}) + return + } + w.mu.Lock() + w.sum.Submitted++ + w.sum.Verdicts[reply.Verdict]++ + w.mu.Unlock() + fields := map[string]any{"verdict": reply.Verdict, "agreeing_count": reply.AgreeingCount, "indexed": reply.Indexed} + if reply.Reason != "" { + fields["reason"] = reply.Reason + } + if reply.CreditsEarned != nil { + fields["credits_earned"] = *reply.CreditsEarned + w.mu.Lock() + w.sum.Credits += *reply.CreditsEarned + w.mu.Unlock() + } + w.emit("submitted", fields) + } +} diff --git a/cmd/cosift/worker_bounds_test.go b/cmd/cosift/worker_bounds_test.go new file mode 100644 index 0000000..3718ef4 --- /dev/null +++ b/cmd/cosift/worker_bounds_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestWorkerStopsAtTheModerationCeilingAndWaitsOnAnEmptyPool(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { + return ok(`{"leases":[],"retry_after_s":3600,"moderation_ceiling":true}`) + } + r := newWorkerRun(t, app, workerOptions{MaxPages: 5}) + if err := r.run(t); err != nil || r.w.summary().StopReason != "moderation_ceiling" || len(app.claimCalls()) != 1 { + t.Fatalf("ceiling: %v %+v", err, r.w.summary()) + } + + empty := newFakeApp(t) + empty.onClaim = func(int, int) (int, http.Header, string) { return ok(`{"leases":[],"retry_after_s":45}`) } + r = newWorkerRun(t, empty, workerOptions{MaxPages: 5}) + if err := r.run(t); err != nil || r.w.summary().StopReason != "no_work" { + t.Fatalf("empty pool: %v %+v", err, r.w.summary()) + } + slept := r.sleeps.slept() + if len(empty.claimCalls()) != 10 || len(slept) != 9 || slept[0] != 45*time.Second { + t.Fatalf("%d claims, slept %v", len(empty.claimCalls()), slept) + } +} + +func TestWorkerAsksForAtMostWhatIsFreeAndAtMost32(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { return ok(emptyPool) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 500, Concurrency: 100}) + r.w.idleLimit = 1 + r.run(t) + if c := app.claimCalls(); len(c) != 1 || c[0] != 32 { + t.Fatalf("claims %v", c) + } + + app = newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { return ok(emptyPool) } + r = newWorkerRun(t, app, workerOptions{MaxPages: 3, Concurrency: 8}) + r.w.idleLimit = 1 + r.run(t) + if c := app.claimCalls(); len(c) != 1 || c[0] != 3 { + t.Fatalf("max-pages not respected: %v", c) + } +} + +func TestWorkerKeepsAtMostConcurrencyLeasesInFlightAndTopsUpFreedSlots(t *testing.T) { + var inflight, peak atomic.Int32 + release := make(chan struct{}) + gate := make(chan struct{}, 16) + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := inflight.Add(1) + for { + p := peak.Load() + if n <= p || peak.CompareAndSwap(p, n) { + break + } + } + gate <- struct{}{} + <-release + inflight.Add(-1) + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "T
"+strings.Repeat("A sentence of ordinary words for the page body. ", 6)+"
") + })) + defer origin.Close() + app := newFakeApp(t) + app.onClaim = func(n, max int) (int, http.Header, string) { + if n > 2 { + return ok(emptyPool) + } + paths := make([]string, max) + for i := range paths { + paths[i] = fmt.Sprintf("/p%d-%d", n, i) + } + return ok(leasesJSON(ev, paths...)) + } + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 4, Concurrency: 3}) + r.w.fetch = originClient(origin) + done := make(chan error, 1) + go func() { done <- r.run(t) }() + for i := 0; i < 3; i++ { + <-gate + } + time.Sleep(100 * time.Millisecond) + if c := app.claimCalls(); len(c) != 1 || c[0] != 3 { + t.Fatalf("a claim was made while every slot was busy: %v", c) + } + release <- struct{}{} + <-gate + for i := 0; i < 3; i++ { + release <- struct{}{} + } + if err := <-done; err != nil { + t.Fatal(err) + } + if c := app.claimCalls(); len(c) != 2 || c[1] != 1 { + t.Fatalf("the freed slot was not topped up by exactly one: %v", c) + } + if peak.Load() > 3 { + t.Fatalf("%d fetches at once, concurrency is 3", peak.Load()) + } + if r.w.summary().Claimed != 4 || r.w.summary().Submitted != 4 { + t.Fatalf("%+v", r.w.summary()) + } +} + +func TestWorkerStopsClaimingAtTheDuration(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { return ok(emptyPool) } + r := newWorkerRun(t, app, workerOptions{Duration: 150 * time.Millisecond}) + r.w.sleep = sleepContext + start := time.Now() + if err := r.run(t); err != nil { + t.Fatal(err) + } + if took := time.Since(start); took > 3*time.Second || r.w.summary().StopReason != "duration" { + t.Fatalf("took %v, %+v", took, r.w.summary()) + } +} + +func TestWorkerRunNeedsABound(t *testing.T) { + app := newFakeApp(t) + r := newWorkerRun(t, app, workerOptions{}) + if err := r.run(t); err == nil || len(app.claimCalls()) != 0 { + t.Fatalf("got %v", err) + } +} diff --git a/cmd/cosift/worker_claim_refusal_test.go b/cmd/cosift/worker_claim_refusal_test.go new file mode 100644 index 0000000..75b77f1 --- /dev/null +++ b/cmd/cosift/worker_claim_refusal_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "net/http" + "strings" + "testing" + "time" +) + +func TestWorkerStopsOnAClaimRefusalItDoesNotClassify(t *testing.T) { + for _, status := range []int{301, 308, 400, 405, 410, 413, 415, 422, 201} { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { + return status, http.Header{"Location": {"http://elsewhere.example.com/"}}, `{"code":"invalid_body"}` + } + r := newWorkerRun(t, app, workerOptions{MaxPages: 5, Duration: 300 * time.Millisecond}) + err := r.run(t) + if err == nil || !strings.Contains(err.Error(), "refused the claim") { + t.Errorf("%d: got %v", status, err) + } + if n := len(app.claimCalls()); n != 1 { + t.Errorf("%d: %d claims", status, n) + } + if r.w.summary().StopReason != "claim_refused" { + t.Errorf("%d: stop reason %q", status, r.w.summary().StopReason) + } + } +} diff --git a/cmd/cosift/worker_flow_test.go b/cmd/cosift/worker_flow_test.go new file mode 100644 index 0000000..785669b --- /dev/null +++ b/cmd/cosift/worker_flow_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/pilot-protocol/cosift/internal/crawler" +) + +func TestWorkerClaimsFetchesAndSubmitsAnAttestation(t *testing.T) { + app := newFakeApp(t) + app.onClaim = queue(ev, "/plain.html", "/german.html") + app.onSubmit = func(n int, _ map[string]any) (int, http.Header, string) { + return ok(`{"verdict":"verified","agreeing_count":2,"indexed":true,"credits_earned":2}`) + } + r := newWorkerRun(t, app, workerOptions{MaxPages: 2, Concurrency: 4}) + if err := r.run(t); err != nil { + t.Fatal(err) + } + subs := app.submitted() + if len(subs) != 2 { + t.Fatalf("%d submissions", len(subs)) + } + for _, s := range subs { + for _, name := range []string{"lease_id", "final_url", "http_status", "title", "text", "lang", "content_hash", "simhash", "extract_version"} { + if _, has := s[name]; !has { + t.Errorf("submission lacks %s", name) + } + } + for _, name := range []string{"worker", "net", "artifact"} { + if _, has := s[name]; has { + t.Errorf("submission carries %s", name) + } + } + text := s["text"].(string) + sum, _ := crawler.Simhash(text) + if s["http_status"] != float64(200) || s["content_hash"] != crawler.ContentHash(text) || s["simhash"] != crawler.SimhashHex(sum) || s["extract_version"] != float64(ev) { + t.Errorf("inconsistent submission: %v", s) + } + } + sub := r.eventsNamed(t, "submitted") + if len(sub) != 2 || sub[0]["verdict"] != "verified" || sub[0]["agreeing_count"] != float64(2) || sub[0]["indexed"] != true || sub[0]["credits_earned"] != float64(2) { + t.Errorf("progress lines: %v", sub) + } + if c := r.eventsNamed(t, "claimed"); len(c) != 1 || c[0]["count"] != float64(2) { + t.Errorf("claimed lines: %v", c) + } + last := r.events(t)[len(r.events(t))-1] + if last["event"] != "summary" || last["claimed"] != float64(2) || last["submitted"] != float64(2) || last["stop_reason"] != "max_pages" { + t.Errorf("summary: %v", last) + } + if v := last["verdicts"].(map[string]any); v["verified"] != float64(2) { + t.Errorf("verdicts: %v", v) + } +} + +func TestWorkerReportsAFailureForNon200AndForNothingElse(t *testing.T) { + app := newFakeApp(t) + app.onClaim = queue(ev, "/blocked", "/missing", "/partial", "/image.png", "/empty.html", "/to-private") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { + return ok(`{"verdict":"released","agreeing_count":0,"indexed":false}`) + } + r := newWorkerRun(t, app, workerOptions{MaxPages: 6, Concurrency: 6}) + if err := r.run(t); err != nil { + t.Fatal(err) + } + got := map[string]float64{} + for _, s := range app.submitted() { + if len(s) != 2 || s["lease_id"] == nil || s["http_status"] == nil { + t.Errorf("a failure report carries only lease_id and http_status: %v", s) + } + got[s["lease_id"].(string)] = s["http_status"].(float64) + } + want := map[string]float64{"lease00": 403, "lease01": 404, "lease02": 206} + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("reports %v, want %v", got, want) + } + sum := r.w.summary() + if sum.Reported != 3 || sum.Skipped != 3 || sum.Submitted != 0 { + t.Errorf("summary %+v", sum) + } +} + +func TestWorkerSkipsLeasesItCannotSubmitWithoutFetchingForeignTargets(t *testing.T) { + app := newFakeApp(t) + app.onClaim = once(`{"leases":[{"lease_id":"l0","url":"http://127.0.0.1/secret","extract_version":`+fmt.Sprint(ev)+`},{"lease_id":"l1","url":"http://intranet.internal/x","extract_version":`+fmt.Sprint(ev)+`},{"lease_id":"l2","url":"http://pages.example.com/tiny","extract_version":`+fmt.Sprint(ev)+`}]}`, emptyPool) + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 3, Concurrency: 3}) + short := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r.hits.add(req) + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "xtoo short") + })) + defer short.Close() + r.w.fetch = originClient(short) + if err := r.run(t); err != nil { + t.Fatal(err) + } + if len(r.hits.seen) != 1 || len(app.submitted()) != 0 || r.w.summary().Skipped != 3 { + t.Fatalf("%d fetches, %d submissions, %+v", len(r.hits.seen), len(app.submitted()), r.w.summary()) + } +} diff --git a/cmd/cosift/worker_interrupt_test.go b/cmd/cosift/worker_interrupt_test.go new file mode 100644 index 0000000..12ab839 --- /dev/null +++ b/cmd/cosift/worker_interrupt_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "net/http" + "testing" + "time" +) + +func TestWorkerInterruptLetsInFlightLeasesFinishWithinTheGraceAndSummarises(t *testing.T) { + app := newFakeApp(t) + app.onClaim = queue(ev, "/a", "/b", "/c", "/d") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 10, Concurrency: 2}) + origin, gate, release := blockingOrigin(t) + r.w.fetch = originClient(origin) + ctx, interrupt := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- r.w.run(ctx) }() + <-gate + <-gate + interrupt() + time.Sleep(50 * time.Millisecond) + release() + if err := <-done; err != nil { + t.Fatal(err) + } + s := r.w.summary() + if s.Submitted != 2 || s.Abandoned != 0 || s.StopReason != "interrupted" || len(app.claimCalls()) != 1 { + t.Fatalf("%+v after %d claims", s, len(app.claimCalls())) + } + events := r.events(t) + if events[len(events)-1]["event"] != "summary" || events[len(events)-1]["stop_reason"] != "interrupted" { + t.Fatalf("last line: %v", events[len(events)-1]) + } +} + +func TestWorkerInterruptAbandonsLeasesThatOutlastTheGrace(t *testing.T) { + app := newFakeApp(t) + app.onClaim = queue(ev, "/a", "/b") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 10, Concurrency: 2}) + r.w.grace = 100 * time.Millisecond + origin, gate, _ := blockingOrigin(t) + r.w.fetch = originClient(origin) + ctx, interrupt := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- r.w.run(ctx) }() + <-gate + <-gate + start := time.Now() + interrupt() + if err := <-done; err != nil { + t.Fatal(err) + } + if took := time.Since(start); took > 3*time.Second { + t.Fatalf("took %v to give up after the grace", took) + } + if s := r.w.summary(); s.Abandoned != 2 || s.Submitted != 0 || s.StopReason != "interrupted" { + t.Fatalf("%+v", s) + } + events := r.events(t) + if events[len(events)-1]["event"] != "summary" || events[len(events)-1]["abandoned"] != float64(2) { + t.Fatalf("last line: %v", events[len(events)-1]) + } +} diff --git a/cmd/cosift/worker_rig_test.go b/cmd/cosift/worker_rig_test.go new file mode 100644 index 0000000..5c5fb0f --- /dev/null +++ b/cmd/cosift/worker_rig_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/pilot-protocol/cosift/internal/crawler" +) + +type fakeApp struct { + mu sync.Mutex + claims []int + submits []map[string]any + onClaim func(n, max int) (int, http.Header, string) + onSubmit func(n int, body map[string]any) (int, http.Header, string) + srv *httptest.Server +} + +func newFakeApp(t *testing.T) *fakeApp { + t.Helper() + a := &fakeApp{} + a.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + json.NewDecoder(r.Body).Decode(&body) + a.mu.Lock() + var status int + var header http.Header + var out string + switch r.URL.Path { + case "/api/work/claim": + max := int(body["max"].(float64)) + a.claims = append(a.claims, max) + status, header, out = a.onClaim(len(a.claims), max) + case "/api/work/submit": + a.submits = append(a.submits, body) + status, header, out = a.onSubmit(len(a.submits), body) + default: + status, out = 404, `{"code":"not_found"}` + } + a.mu.Unlock() + for k, v := range header { + w.Header()[k] = v + } + w.WriteHeader(status) + fmt.Fprint(w, out) + })) + t.Cleanup(a.srv.Close) + return a +} + +func (a *fakeApp) claimCalls() []int { + a.mu.Lock() + defer a.mu.Unlock() + return append([]int(nil), a.claims...) +} + +func (a *fakeApp) submitted() []map[string]any { + a.mu.Lock() + defer a.mu.Unlock() + return append([]map[string]any(nil), a.submits...) +} + +func leasesJSON(version int, paths ...string) string { + var parts []string + for i, p := range paths { + parts = append(parts, fmt.Sprintf(`{"lease_id":"lease%02d","url":"http://pages.example.com%s","source":"errored","expires_at":"2026-10-20T10:10:00Z","extract_version":%d}`, i, p, version)) + } + return `{"leases":[` + strings.Join(parts, ",") + `]}` +} + +func ok(body string) (int, http.Header, string) { return 200, nil, body } + +// queue serves the paths as leases, at most max a claim, then an empty pool. + +func queue(version int, paths ...string) func(n, max int) (int, http.Header, string) { + var mu sync.Mutex + next := 0 + return func(_, max int) (int, http.Header, string) { + mu.Lock() + defer mu.Unlock() + if next >= len(paths) { + return ok(emptyPool) + } + end := min(next+max, len(paths)) + var parts []string + for i := next; i < end; i++ { + parts = append(parts, fmt.Sprintf(`{"lease_id":"lease%02d","url":"http://pages.example.com%s","source":"errored","expires_at":"2026-10-20T10:10:00Z","extract_version":%d}`, i, paths[i], version)) + } + next = end + return ok(`{"leases":[` + strings.Join(parts, ",") + `]}`) + } +} + +func once(first, then string) func(n, max int) (int, http.Header, string) { + return func(n, _ int) (int, http.Header, string) { + if n == 1 { + return ok(first) + } + return ok(then) + } +} + +const ( + verdictWaiting = `{"verdict":"awaiting_quorum","agreeing_count":0,"indexed":false}` + emptyPool = `{"leases":[],"retry_after_s":30}` +) + +type sleepLog struct { + mu sync.Mutex + d []time.Duration +} + +func (s *sleepLog) sleep(ctx context.Context, d time.Duration) error { + s.mu.Lock() + s.d = append(s.d, d) + s.mu.Unlock() + return ctx.Err() +} + +func (s *sleepLog) slept() []time.Duration { + s.mu.Lock() + defer s.mu.Unlock() + return append([]time.Duration(nil), s.d...) +} + +type workerRun struct { + w *worker + out bytes.Buffer + errOut bytes.Buffer + sleeps *sleepLog + origin *httptest.Server + hits *headerLog +} + +func newWorkerRun(t *testing.T, app *fakeApp, opts workerOptions) *workerRun { + t.Helper() + origin, hits := fixtureOrigin(t) + r := &workerRun{sleeps: &sleepLog{}, origin: origin, hits: hits} + r.w = newWorker(newWorkAPI(app.srv.URL, workerTestToken), originClient(origin), opts, &r.out, &r.errOut) + r.w.sleep = r.sleeps.sleep + r.w.grace = time.Second + return r +} + +func (r *workerRun) run(t *testing.T) error { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + err := r.w.run(ctx) + if strings.Contains(r.out.String()+r.errOut.String()+fmt.Sprint(err), "AAAAAAAAAAAAAAAA") { + t.Fatalf("the token reached the output: %q %q %v", r.out.String(), r.errOut.String(), err) + } + return err +} + +func (r *workerRun) events(t *testing.T) []map[string]any { + t.Helper() + var out []map[string]any + for _, line := range strings.Split(strings.TrimSpace(r.out.String()), "\n") { + if line == "" { + continue + } + var m map[string]any + if err := json.Unmarshal([]byte(line), &m); err != nil { + t.Fatalf("stdout line is not JSON: %q", line) + } + out = append(out, m) + } + return out +} + +func (r *workerRun) eventsNamed(t *testing.T, name string) []map[string]any { + var out []map[string]any + for _, e := range r.events(t) { + if e["event"] == name { + out = append(out, e) + } + } + return out +} + +var ev = crawler.ExtractVersion + +func blockingOrigin(t *testing.T) (srv *httptest.Server, gate chan struct{}, release func()) { + t.Helper() + gate, open := make(chan struct{}, 16), make(chan struct{}) + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gate <- struct{}{} + <-open + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "T
"+strings.Repeat("A sentence of ordinary words for the page body. ", 6)+"
") + })) + t.Cleanup(srv.Close) + var once sync.Once + release = func() { once.Do(func() { close(open) }) } + t.Cleanup(release) + return srv, gate, release +} From e05b2a5d81737720c4feea0008d0c89520011afb Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 20:25:52 +0300 Subject: [PATCH 104/121] test(cosift): worker retry, back-off, stop and guidance rules --- cmd/cosift/worker_pins_test.go | 105 ++++++++++++++++ cmd/cosift/worker_retry_test.go | 213 ++++++++++++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 cmd/cosift/worker_pins_test.go create mode 100644 cmd/cosift/worker_retry_test.go diff --git a/cmd/cosift/worker_pins_test.go b/cmd/cosift/worker_pins_test.go new file mode 100644 index 0000000..8422472 --- /dev/null +++ b/cmd/cosift/worker_pins_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestWorkerKeepsOnlyTheLeasesItAskedFor(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { + return ok(leasesJSON(ev, "/plain.html", "/german.html", "/moved")) + } + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1, Concurrency: 1}) + if err := r.run(t); err != nil { + t.Fatal(err) + } + if s := r.w.summary(); s.Claimed != 1 || s.Submitted != 1 || len(app.submitted()) != 1 { + t.Fatalf("%+v, %d submissions", s, len(app.submitted())) + } +} + +func TestWorkerRefusesALeaseOfAnOlderExtractVersion(t *testing.T) { + app := newFakeApp(t) + app.onClaim = queue(ev-1, "/plain.html") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1}) + if err := r.run(t); err == nil || !strings.Contains(err.Error(), "upgrade cosift") || len(r.hits.seen) != 0 { + t.Fatalf("got %v after %d fetches", err, len(r.hits.seen)) + } +} + +func TestWorkerFetchesPagesUpToTheServersBodyCap(t *testing.T) { + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, "Big
"+strings.Repeat("A sentence of ordinary words for the page body. ", 6)+"
") + })) + t.Cleanup(origin.Close) + app := newFakeApp(t) + app.onClaim = queue(ev, "/big.html") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1}) + r.w.fetch = originClient(origin) + if err := r.run(t); err != nil { + t.Fatal(err) + } + if s := r.w.summary(); s.Submitted != 1 || s.Skipped != 0 { + t.Fatalf("%+v", s) + } +} + +func TestWorkerLeaseRetryWaitsGrow(t *testing.T) { + app := newFakeApp(t) + app.onClaim = queue(ev, "/plain.html") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return 409, nil, `{"code":"lease_expired"}` } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1}) + if err := r.run(t); err != nil { + t.Fatal(err) + } + if got, want := r.sleeps.slept(), []time.Duration{time.Second, 2 * time.Second}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("slept %v, want %v", got, want) + } +} + +func TestClassifyTreatsAnUnknownConflictAsASkip(t *testing.T) { + if d := classifyWorkErr(&workAPIError{Status: 409, Code: "something_else"}); d.act != actSkip { + t.Fatalf("got %+v", d) + } +} + +func TestWorkerCapsAServerSuppliedIdleWait(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { return ok(`{"leases":[],"retry_after_s":1000000000}`) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1}) + r.w.idleLimit = 2 + if err := r.run(t); err != nil { + t.Fatal(err) + } + if got := r.sleeps.slept(); len(got) != 1 || got[0] != idleWaitCap { + t.Fatalf("slept %v", got) + } +} + +func TestWorkerCountsOnlyConsecutiveEmptyClaimsTowardsTheIdleLimit(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(n, _ int) (int, http.Header, string) { + if n%3 == 0 { + return ok(leasesJSON(ev, "/plain.html")) + } + return ok(emptyPool) + } + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 3, Concurrency: 1}) + r.w.idleLimit = 3 + if err := r.run(t); err != nil { + t.Fatal(err) + } + if s := r.w.summary(); s.Claimed != 3 || s.StopReason != "max_pages" { + t.Fatalf("%+v", s) + } +} diff --git a/cmd/cosift/worker_retry_test.go b/cmd/cosift/worker_retry_test.go new file mode 100644 index 0000000..ffe6ed3 --- /dev/null +++ b/cmd/cosift/worker_retry_test.go @@ -0,0 +1,213 @@ +package main + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +func TestWorkerRetriesAnExpiredLeaseAndGivesUpAfterABoundedNumberOfTries(t *testing.T) { + for _, code := range []string{"lease_expired", "lease_not_owner"} { + app := newFakeApp(t) + app.onClaim = queue(ev, "/plain.html") + app.onSubmit = func(n int, _ map[string]any) (int, http.Header, string) { + if n == 1 { + return 409, nil, `{"code":"` + code + `"}` + } + return ok(verdictWaiting) + } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1}) + if err := r.run(t); err != nil { + t.Fatal(err) + } + if len(app.submitted()) != 2 || r.w.summary().Submitted != 1 || r.w.summary().LeaseLost != 0 { + t.Errorf("%s: %d submissions, %+v", code, len(app.submitted()), r.w.summary()) + } + if s := r.sleeps.slept(); len(s) != 1 || s[0] != time.Second { + t.Errorf("%s: slept %v", code, s) + } + + always := newFakeApp(t) + always.onClaim = queue(ev, "/plain.html", "/german.html") + always.onSubmit = func(int, map[string]any) (int, http.Header, string) { return 409, nil, `{"code":"` + code + `"}` } + r = newWorkerRun(t, always, workerOptions{MaxPages: 2, Concurrency: 1}) + if err := r.run(t); err != nil { + t.Fatalf("%s: a lost lease must not end the run: %v", code, err) + } + if len(always.submitted()) != 6 || r.w.summary().LeaseLost != 2 { + t.Errorf("%s: %d submissions, %+v", code, len(always.submitted()), r.w.summary()) + } + } +} + +func TestWorkerStopsWithUpgradeGuidanceOnAVersionMismatch(t *testing.T) { + cases := map[string]func(*fakeApp){ + "submit 409": func(a *fakeApp) { + a.onClaim = queue(ev, "/plain.html", "/german.html") + a.onSubmit = func(int, map[string]any) (int, http.Header, string) { + return 409, nil, fmt.Sprintf(`{"code":"extract_version_mismatch","extract_version":%d}`, ev+1) + } + }, + "content_hash 422": func(a *fakeApp) { + a.onClaim = queue(ev, "/plain.html", "/german.html") + a.onSubmit = func(int, map[string]any) (int, http.Header, string) { + return 422, nil, `{"code":"invalid_field","field":"content_hash","rule":"mismatch"}` + } + }, + "simhash 422": func(a *fakeApp) { + a.onClaim = queue(ev, "/plain.html", "/german.html") + a.onSubmit = func(int, map[string]any) (int, http.Header, string) { + return 422, nil, `{"code":"invalid_field","field":"simhash","rule":"mismatch"}` + } + }, + } + for name, setup := range cases { + app := newFakeApp(t) + setup(app) + r := newWorkerRun(t, app, workerOptions{MaxPages: 10, Concurrency: 1}) + err := r.run(t) + if err == nil || !strings.Contains(err.Error(), "upgrade cosift") { + t.Errorf("%s: want upgrade guidance, got %v", name, err) + } + if len(app.claimCalls()) != 1 || len(app.submitted()) != 1 { + t.Errorf("%s: kept going: %d claims, %d submissions", name, len(app.claimCalls()), len(app.submitted())) + } + if r.w.summary().StopReason != "upgrade_required" { + t.Errorf("%s: stop reason %q", name, r.w.summary().StopReason) + } + } +} + +func TestWorkerRefusesALeaseOfAnotherExtractVersionBeforeFetching(t *testing.T) { + app := newFakeApp(t) + app.onClaim = queue(ev+1, "/plain.html") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1}) + err := r.run(t) + if err == nil || !strings.Contains(err.Error(), "upgrade cosift") { + t.Fatalf("got %v", err) + } + if len(r.hits.seen) != 0 || len(app.submitted()) != 0 { + t.Fatal("the page was fetched or submitted") + } +} + +func TestWorkerOtherFieldRefusalsSkipTheLeaseAndContinue(t *testing.T) { + app := newFakeApp(t) + app.onClaim = queue(ev, "/plain.html", "/german.html") + app.onSubmit = func(n int, _ map[string]any) (int, http.Header, string) { + if n == 1 { + return 422, nil, `{"code":"invalid_field","field":"final_url","rule":"off_host"}` + } + return ok(verdictWaiting) + } + r := newWorkerRun(t, app, workerOptions{MaxPages: 2, Concurrency: 1}) + if err := r.run(t); err != nil { + t.Fatal(err) + } + if s := r.w.summary(); s.Submitted != 1 || s.Skipped != 1 || len(app.submitted()) != 2 { + t.Fatalf("%+v", s) + } +} + +func TestWorkerStopsWithAClearMessageOnAccountRefusals(t *testing.T) { + for _, tc := range []struct{ code, want string }{ + {"not_joined", "contributor network"}, + {"banned", "suspended"}, + {"terms_outdated", "accept the current contributor terms"}, + } { + for _, onSubmit := range []bool{false, true} { + app := newFakeApp(t) + refuse := func() (int, http.Header, string) { return 403, nil, `{"code":"` + tc.code + `"}` } + app.onClaim = func(int, int) (int, http.Header, string) { + if !onSubmit { + return refuse() + } + return ok(leasesJSON(ev, "/plain.html")) + } + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return refuse() } + r := newWorkerRun(t, app, workerOptions{MaxPages: 5}) + err := r.run(t) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s (submit=%v): got %v", tc.code, onSubmit, err) + } + if len(app.claimCalls()) != 1 || len(app.submitted()) > 1 || len(r.sleeps.slept()) != 0 { + t.Errorf("%s: retried a refusal", tc.code) + } + if r.w.summary().StopReason != tc.code { + t.Errorf("%s: stop reason %q", tc.code, r.w.summary().StopReason) + } + } + } +} + +func TestWorkerStopsOnAnUnauthorizedToken(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { return 401, nil, `{"code":"bearer_required"}` } + r := newWorkerRun(t, app, workerOptions{MaxPages: 5}) + err := r.run(t) + if err == nil || !strings.Contains(err.Error(), "bearer") || len(app.claimCalls()) != 1 { + t.Fatalf("got %v after %d claims", err, len(app.claimCalls())) + } +} + +func TestWorkerStopsAtTheDailyCapAndSaysWhenItResets(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { + return 429, http.Header{"Retry-After": {"7200"}}, `{"code":"daily_cap","retry_after_seconds":7200}` + } + r := newWorkerRun(t, app, workerOptions{MaxPages: 5}) + r.w.now = func() time.Time { return time.Date(2026, 10, 14, 22, 0, 0, 0, time.UTC) } + if err := r.run(t); err != nil { + t.Fatalf("a spent daily cap is a normal end: %v", err) + } + if !strings.Contains(r.errOut.String(), "2026-10-15T00:00:00Z") || len(app.claimCalls()) != 1 || r.w.summary().StopReason != "daily_cap" { + t.Fatalf("%q after %d claims, %+v", r.errOut.String(), len(app.claimCalls()), r.w.summary()) + } +} + +func TestWorkerBacksOffOnRateLimitsAndUnavailability(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(n, _ int) (int, http.Header, string) { + switch n { + case 1: + return 429, http.Header{"Retry-After": {"7"}}, `{"code":"rate_limited","retry_after_seconds":7}` + case 2, 3, 4: + return 503, nil, `{"code":"engine_unavailable"}` + } + return ok(emptyPool) + } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1}) + r.w.idleLimit = 1 + if err := r.run(t); err != nil { + t.Fatal(err) + } + want := []time.Duration{7 * time.Second, time.Second, 2 * time.Second, 4 * time.Second} + if got := r.sleeps.slept(); fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("slept %v, want %v", got, want) + } +} + +func TestWorkerGivesUpAfterBoundedRetries(t *testing.T) { + for _, status := range []int{429, 503, 500} { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { return status, nil, `{"code":"rate_limited"}` } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1}) + err := r.run(t) + if err == nil || len(app.claimCalls()) != 5 { + t.Errorf("%d: got %v after %d claims", status, err, len(app.claimCalls())) + } + } + app := newFakeApp(t) + app.onClaim = queue(ev, "/plain.html") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return 503, nil, `{"code":"engine_unavailable"}` } + r := newWorkerRun(t, app, workerOptions{MaxPages: 1}) + if err := r.run(t); err != nil { + t.Fatalf("a submit that stays unavailable abandons only its lease: %v", err) + } + if len(app.submitted()) != 5 || r.w.summary().Abandoned != 1 { + t.Fatalf("%d submissions, %+v", len(app.submitted()), r.w.summary()) + } +} From c037edf1ce8a273082e07b3ef0bb7010b8e9261f Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 20:25:52 +0300 Subject: [PATCH 105/121] test(cosift): the worker stops on a real SIGINT --- cmd/cosift/worker_signal_test.go | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 cmd/cosift/worker_signal_test.go diff --git a/cmd/cosift/worker_signal_test.go b/cmd/cosift/worker_signal_test.go new file mode 100644 index 0000000..8daad5c --- /dev/null +++ b/cmd/cosift/worker_signal_test.go @@ -0,0 +1,37 @@ +//go:build !windows + +package main + +import ( + "context" + "net/http" + "os/signal" + "syscall" + "testing" + "time" +) + +func TestWorkerStopsOnARealSIGINT(t *testing.T) { + app := newFakeApp(t) + app.onClaim = queue(ev, "/a") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + r := newWorkerRun(t, app, workerOptions{MaxPages: 10, Concurrency: 1}) + origin, gate, release := blockingOrigin(t) + r.w.fetch = originClient(origin) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT) + defer stop() + done := make(chan error, 1) + go func() { done <- r.w.run(ctx) }() + <-gate + if err := syscall.Kill(syscall.Getpid(), syscall.SIGINT); err != nil { + t.Fatal(err) + } + time.Sleep(100 * time.Millisecond) + release() + if err := <-done; err != nil { + t.Fatal(err) + } + if s := r.w.summary(); s.StopReason != "interrupted" || s.Submitted != 1 { + t.Fatalf("%+v", s) + } +} From dd5add01943583f9ece1e9358e25b08327b86c5a Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 20:36:18 +0300 Subject: [PATCH 106/121] feat(cosift): contribute -worker, with bearer-only authentication and bounded options --- cmd/cosift/community.go | 20 ++- cmd/cosift/main.go | 1 + cmd/cosift/worker_cmd.go | 60 +++++++++ cmd/cosift/worker_cmd_test.go | 228 ++++++++++++++++++++++++++++++++++ cmd/cosift/worker_rig_test.go | 8 ++ 5 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 cmd/cosift/worker_cmd.go create mode 100644 cmd/cosift/worker_cmd_test.go diff --git a/cmd/cosift/community.go b/cmd/cosift/community.go index 2801ecf..c262255 100644 --- a/cmd/cosift/community.go +++ b/cmd/cosift/community.go @@ -169,14 +169,25 @@ func runContributeConfigured(ctx context.Context, cfg *config.Config, args []str login := fs.Bool("login", false, "save an authenticated CLI session") logout := fs.Bool("logout", false, "revoke and delete the saved CLI session") guest := fs.Bool("guest", false, "perform Search, Answer or Research without login; contributions require login") + workerMode := fs.Bool("worker", false, "fetch blocked pages leased by the network and submit them (needs your bearer token)") + maxPages := fs.Int("max-pages", 100, "worker: stop after claiming this many pages (0 = no page limit)") + duration := fs.Duration("duration", 30*time.Minute, "worker: stop claiming after this long (0 = no time limit)") + concurrency := fs.Int("concurrency", 4, "worker: leases in flight at once, 1 to 32") if err := fs.Parse(args); err != nil { return err } - var explicitServer, explicitSession bool + var explicitServer, explicitSession, workerFlag bool fs.Visit(func(f *flag.Flag) { explicitServer = explicitServer || f.Name == "server" explicitSession = explicitSession || f.Name == "session-file" + workerFlag = workerFlag || f.Name == "max-pages" || f.Name == "duration" || f.Name == "concurrency" }) + if workerFlag && !*workerMode { + return fmt.Errorf("-max-pages, -duration and -concurrency require -worker") + } + if *workerMode && (*login || *logout || *guest || *requestMode || *local || *credits || *file != "" || *query != "" || fs.NArg() > 0) { + return fmt.Errorf("-worker cannot be combined with other operations") + } // The installer can provision this one well-known session. Explicit // credentials and guest mode always win; no harness config is inspected. if !*guest && !*login && !explicitSession && *sessionFile == "" && os.Getenv("COSIFT_TOKEN") == "" && *email == "" && os.Getenv("COSIFT_PASSWORD") == "" { @@ -211,6 +222,13 @@ func runContributeConfigured(ctx context.Context, cfg *config.Config, args []str if token != "" && *sessionFile != "" && !*login { return fmt.Errorf("choose COSIFT_TOKEN or a saved session, not both") } + if *workerMode { + bearer, err := workerBearer(token, *sessionFile, strings.TrimRight(*server, "/"), password != "" || *email != "") + if err != nil { + return err + } + return runWorkerMode(ctx, strings.TrimRight(*server, "/"), bearer, workerOptions{MaxPages: *maxPages, Duration: *duration, Concurrency: *concurrency}, os.Stdout, os.Stderr) + } if token == "" && !*guest && (*login || *sessionFile == "") && ((*email == "") != (password == "")) { return fmt.Errorf("set both COSIFT_EMAIL and COSIFT_PASSWORD, or use -guest for retrieval") } diff --git a/cmd/cosift/main.go b/cmd/cosift/main.go index e938ed4..f9a6129 100644 --- a/cmd/cosift/main.go +++ b/cmd/cosift/main.go @@ -27,6 +27,7 @@ usage: cosift serve run the HTTP API (port from config) cosift community run the contributor web app (login, interests, saved searches, URL/CSV submissions) cosift contribute [-server URL] [-guest] [-email EMAIL] [-csv FILE] submit URLs as a guest or member + cosift contribute -worker [-max-pages N] [-duration D] [-concurrency C] fetch leased pages for the network (bearer token required) cosift crawl one-shot crawl of seed URLs cosift check-robots report whether each URL is crawlable per the site's robots.txt cosift crawl-errors [-limit N] list recently-errored frontier URLs with their failure reason diff --git a/cmd/cosift/worker_cmd.go b/cmd/cosift/worker_cmd.go new file mode 100644 index 0000000..baafa43 --- /dev/null +++ b/cmd/cosift/worker_cmd.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/pilot-protocol/cosift/internal/crawler" + "github.com/pilot-protocol/cosift/internal/sharedaccount" +) + +const workerBearerAdvice = "worker mode needs your bearer token (ck_...), not a cookie session: set COSIFT_TOKEN to the token cosift-install wrote, or run cosift login with COSIFT_TOKEN set so the saved session holds it" + +var errNeedsBearer = errors.New(workerBearerAdvice) + +func newWorkerFetchClient() *http.Client { return crawler.BrowserClient(30 * time.Second) } + +// workerFetchClient builds the client pages are fetched with; tests replace it. +var workerFetchClient = newWorkerFetchClient + +// workerBearer returns the ck_ token worker mode authenticates with, from COSIFT_TOKEN or a saved session; a cookie-only session is refused before any request. +func workerBearer(token, sessionFile, origin string, hasPassword bool) (string, error) { + switch { + case token != "": + return token, nil + case sessionFile != "": + saved, err := readCommunitySession(sessionFile, origin, false) + if err != nil { + return "", err + } + if _, err := sharedaccount.Parse(saved.Token); err != nil { + return "", errNeedsBearer + } + return saved.Token, nil + case hasPassword: + return "", errNeedsBearer + } + return "", fmt.Errorf("worker mode needs credentials: %s", workerBearerAdvice) +} + +func validateWorkerOptions(o workerOptions) error { + switch { + case o.MaxPages < 0 || o.Duration < 0: + return errors.New("-max-pages and -duration cannot be negative") + case o.Concurrency < 1 || o.Concurrency > maxClaim: + return fmt.Errorf("-concurrency must be from 1 to %d", maxClaim) + } + return nil +} + +func runWorkerMode(ctx context.Context, origin, token string, opts workerOptions, stdout, stderr io.Writer) error { + if err := validateWorkerOptions(opts); err != nil { + return err + } + w := newWorker(newWorkAPI(origin, token), workerFetchClient(), opts, stdout, stderr) + return w.run(ctx) +} diff --git a/cmd/cosift/worker_cmd_test.go b/cmd/cosift/worker_cmd_test.go new file mode 100644 index 0000000..fe99496 --- /dev/null +++ b/cmd/cosift/worker_cmd_test.go @@ -0,0 +1,228 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// runWorkerCLI runs `cosift contribute ` with COSIFT_TOKEN as given and returns stdout, stderr text and the error. +func runWorkerCLI(t *testing.T, app *fakeApp, token string, args ...string) (stdout, stderr string, err error) { + t.Helper() + return runWorkerCLIEnv(t, app, token, nil, args...) +} + +func runWorkerCLIEnv(t *testing.T, app *fakeApp, token string, env map[string]string, args ...string) (stdout, stderr string, err error) { + t.Helper() + origin, _ := fixtureOrigin(t) + previous := workerFetchClient + workerFetchClient = func() *http.Client { return originClient(origin) } + t.Cleanup(func() { workerFetchClient = previous }) + t.Setenv("COSIFT_TOKEN", token) + t.Setenv("COSIFT_EMAIL", "") + t.Setenv("COSIFT_PASSWORD", "") + t.Setenv("COSIFT_SESSION_FILE", "") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + for k, v := range env { + t.Setenv(k, v) + } + outR, outW, _ := os.Pipe() + errR, errW, _ := os.Pipe() + prevOut, prevErr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = outW, errW + done := make(chan struct{}) + var outBuf, errBuf bytes.Buffer + go func() { outBuf.ReadFrom(outR); close(done) }() + errDone := make(chan struct{}) + go func() { errBuf.ReadFrom(errR); close(errDone) }() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + err = runContributeConfigured(ctx, nil, args) + outW.Close() + errW.Close() + os.Stdout, os.Stderr = prevOut, prevErr + <-done + <-errDone + if token != "" && (strings.Contains(outBuf.String(), token) || strings.Contains(errBuf.String(), token) || (err != nil && strings.Contains(err.Error(), token))) { + t.Fatalf("the token reached the output: %q %q %v", outBuf.String(), errBuf.String(), err) + } + return outBuf.String(), errBuf.String(), err +} + +func writeSession(t *testing.T, dir, origin, token string) string { + t.Helper() + path := filepath.Join(dir, "session.json") + raw, _ := json.Marshal(communitySession{Origin: origin, Token: token, Expires: time.Now().Add(24 * time.Hour)}) + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func workerApp(t *testing.T) *fakeApp { + app := newFakeApp(t) + app.onClaim = queue(ev, "/plain.html", "/german.html") + app.onSubmit = func(int, map[string]any) (int, http.Header, string) { return ok(verdictWaiting) } + return app +} + +func TestContributeWorkerAuthenticatesWithTheBearerTokenAndTheCommunityHeader(t *testing.T) { + app := workerApp(t) + stdout, _, err := runWorkerCLI(t, app, workerTestToken, "-worker", "-server", app.srv.URL, "-max-pages", "2", "-concurrency", "2") + if err != nil { + t.Fatal(err) + } + headers := app.requestHeaders() + if len(headers) < 3 { + t.Fatalf("%d requests", len(headers)) + } + for _, h := range headers { + if h.Get("Authorization") != "Bearer "+workerTestToken || h.Get("X-Cosift-Client") != "community" { + t.Errorf("headers: %v", h) + } + if _, has := h["Origin"]; has { + t.Error("an Origin header was sent") + } + if _, has := h["Cookie"]; has { + t.Error("a cookie was sent") + } + } + lines := strings.Split(strings.TrimSpace(stdout), "\n") + var last map[string]any + if err := json.Unmarshal([]byte(lines[len(lines)-1]), &last); err != nil || last["event"] != "summary" || last["submitted"] != float64(2) { + t.Fatalf("stdout: %q", stdout) + } +} + +func TestContributeWorkerReadsTheBearerTokenFromASavedSession(t *testing.T) { + app := workerApp(t) + path := writeSession(t, t.TempDir(), app.srv.URL, workerTestToken) + if _, _, err := runWorkerCLI(t, app, "", "-worker", "-server", app.srv.URL, "-session-file", path, "-max-pages", "2", "-concurrency", "2"); err != nil { + t.Fatal(err) + } + for _, h := range app.requestHeaders() { + if h.Get("Authorization") != "Bearer "+workerTestToken { + t.Fatalf("headers: %v", h) + } + if _, has := h["Cookie"]; has { + t.Fatal("a cookie was sent") + } + } + if len(app.submitted()) != 2 { + t.Fatalf("%d submissions", len(app.submitted())) + } +} + +func TestContributeWorkerUsesTheInstalledSessionWithoutAnyFlag(t *testing.T) { + app := workerApp(t) + origin, _ := fixtureOrigin(t) + previous := workerFetchClient + workerFetchClient = func() *http.Client { return originClient(origin) } + t.Cleanup(func() { workerFetchClient = previous }) + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "cosift"), 0o700); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(communitySession{Origin: app.srv.URL, Token: workerTestToken, Expires: time.Now().Add(time.Hour)}) + if err := os.WriteFile(filepath.Join(dir, "cosift", "community-session.json"), raw, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", dir) + for _, k := range []string{"COSIFT_TOKEN", "COSIFT_EMAIL", "COSIFT_PASSWORD", "COSIFT_SESSION_FILE"} { + t.Setenv(k, "") + } + if err := runContributeConfigured(context.Background(), nil, []string{"-worker", "-max-pages", "1"}); err != nil { + t.Fatal(err) + } + if len(app.submitted()) != 1 { + t.Fatalf("%d submissions", len(app.submitted())) + } +} + +func TestContributeWorkerRefusesCookieOnlyCredentialsBeforeAnyRequest(t *testing.T) { + app := workerApp(t) + cookie := writeSession(t, t.TempDir(), app.srv.URL, "opaque-cookie-session-value") + for name, tc := range map[string]struct { + token string + args []string + env map[string]string + }{ + "cookie session": {"", []string{"-session-file", cookie}, nil}, + "email and password": {"", nil, map[string]string{"COSIFT_EMAIL": "me@example.com", "COSIFT_PASSWORD": "hunter2hunter2"}}, + "email only": {"", []string{"-email", "me@example.com"}, nil}, + "no credentials at all": {"", nil, nil}, + } { + _, _, err := runWorkerCLIEnv(t, app, tc.token, tc.env, append([]string{"-worker", "-server", app.srv.URL, "-max-pages", "1"}, tc.args...)...) + if err == nil || !strings.Contains(err.Error(), "COSIFT_TOKEN") || (name != "no credentials at all" && !errors.Is(err, errNeedsBearer)) { + t.Errorf("%s: got %v", name, err) + } + if n := len(app.requestHeaders()); n != 0 { + t.Errorf("%s: %d requests were made", name, n) + } + } +} + +func TestContributeWorkerRejectsMixedOperationsAndBadBounds(t *testing.T) { + app := workerApp(t) + base := []string{"-worker", "-server", app.srv.URL} + for name, extra := range map[string][]string{ + "url": {"https://example.com/a"}, + "csv": {"-csv", "-"}, + "request": {"-request", "-query", "x"}, + "local": {"-index-locally"}, + "credits": {"-credits"}, + "guest": {"-guest"}, + "login": {"-login"}, + "logout": {"-logout"}, + "no bound": {"-max-pages", "0", "-duration", "0"}, + "negative pages": {"-max-pages", "-1"}, + "negative duration": {"-duration", "-1s"}, + "zero concurrency": {"-concurrency", "0"}, + "too much": {"-concurrency", "33"}, + } { + _, _, err := runWorkerCLI(t, app, workerTestToken, append(append([]string(nil), base...), extra...)...) + if err == nil { + t.Errorf("%s: accepted", name) + } + } + if n := len(app.requestHeaders()); n != 0 { + t.Fatalf("%d requests were made", n) + } + _, _, err := runWorkerCLI(t, app, workerTestToken, "-max-pages", "5", "-server", app.srv.URL, "https://example.com/a") + if err == nil || !strings.Contains(err.Error(), "-worker") { + t.Fatalf("worker flags without -worker: %v", err) + } +} + +func TestContributeWorkerReportsAnAccountRefusalWithoutTheToken(t *testing.T) { + app := newFakeApp(t) + app.onClaim = func(int, int) (int, http.Header, string) { + return 403, nil, `{"error":"echo ` + workerTestToken + `","code":"banned"}` + } + stdout, _, err := runWorkerCLI(t, app, workerTestToken, "-worker", "-server", app.srv.URL, "-max-pages", "1") + if err == nil || !strings.Contains(err.Error(), "suspended") { + t.Fatalf("got %v", err) + } + if !strings.Contains(stdout, `"event":"summary"`) { + t.Fatalf("no summary was printed: %q", stdout) + } +} + +func TestTheWorkerFetchClientIsPublicOnly(t *testing.T) { + origin, hits := fixtureOrigin(t) + _, err := newWorkerFetchClient().Get(origin.URL + "/plain.html") + if err == nil || !strings.Contains(err.Error(), "public-only") || len(hits.seen) != 0 { + t.Fatalf("a loopback origin was reached: %v", err) + } + req, _ := http.NewRequest("GET", "http://10.0.0.1/x", nil) + if err := newWorkerFetchClient().CheckRedirect(req, nil); err == nil || !strings.Contains(err.Error(), "public-only") { + t.Fatalf("a private redirect hop was allowed: %v", err) + } +} diff --git a/cmd/cosift/worker_rig_test.go b/cmd/cosift/worker_rig_test.go index 5c5fb0f..a1c7b73 100644 --- a/cmd/cosift/worker_rig_test.go +++ b/cmd/cosift/worker_rig_test.go @@ -21,6 +21,7 @@ type fakeApp struct { submits []map[string]any onClaim func(n, max int) (int, http.Header, string) onSubmit func(n int, body map[string]any) (int, http.Header, string) + headers []http.Header srv *httptest.Server } @@ -31,6 +32,7 @@ func newFakeApp(t *testing.T) *fakeApp { var body map[string]any json.NewDecoder(r.Body).Decode(&body) a.mu.Lock() + a.headers = append(a.headers, r.Header.Clone()) var status int var header http.Header var out string @@ -62,6 +64,12 @@ func (a *fakeApp) claimCalls() []int { return append([]int(nil), a.claims...) } +func (a *fakeApp) requestHeaders() []http.Header { + a.mu.Lock() + defer a.mu.Unlock() + return append([]http.Header(nil), a.headers...) +} + func (a *fakeApp) submitted() []map[string]any { a.mu.Lock() defer a.mu.Unlock() From 402f1116edae8c031c7c33f531c5e485239f1195 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 20:41:54 +0300 Subject: [PATCH 107/121] test(cosift): the worker runs end to end through the community app against the real work routes --- cmd/cosift/worker_e2e_test.go | 150 ++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 cmd/cosift/worker_e2e_test.go diff --git a/cmd/cosift/worker_e2e_test.go b/cmd/cosift/worker_e2e_test.go new file mode 100644 index 0000000..df7b9d2 --- /dev/null +++ b/cmd/cosift/worker_e2e_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/pilot-protocol/cosift/internal/community" + "github.com/pilot-protocol/cosift/internal/sharedaccount" +) + +const ( + e2eUID = "0123456789abcdef" + e2eCID = "0f1e2d3c4b5a6978" +) + +// e2eAccounts is a shared-account provider that knows one token and one joined contributor. +type e2eAccounts struct{} + +func (e2eAccounts) Namespace() string { return "test/staging" } +func (e2eAccounts) Verify(_ context.Context, token string) (sharedaccount.Identity, error) { + if token == workerTestToken { + return sharedaccount.Identity{UID: e2eUID, Email: "worker@example.com"}, nil + } + return sharedaccount.Identity{}, sharedaccount.ErrUnauthorized +} +func (e2eAccounts) Start(context.Context, string) (sharedaccount.Challenge, error) { + return sharedaccount.Challenge{}, sharedaccount.ErrUnavailable +} +func (e2eAccounts) Finish(context.Context, string, string) (sharedaccount.Issued, error) { + return sharedaccount.Issued{}, sharedaccount.ErrUnavailable +} +func (e2eAccounts) Revoke(context.Context, string) error { return nil } +func (e2eAccounts) Call(context.Context, string, string, map[string]any) (map[string]any, error) { + return nil, sharedaccount.ErrUnavailable +} +func (e2eAccounts) JoinState(context.Context, string) (sharedaccount.JoinState, error) { + return sharedaccount.JoinState{CID: e2eCID, TermsVersion: 1}, nil +} +func (e2eAccounts) AddCounters(context.Context, string, map[string]int64) error { return nil } +func (e2eAccounts) RaiseAlert(context.Context, sharedaccount.Alert) error { return nil } + +func openApp(t *testing.T, engineURL, brokerKey string, work bool) *httptest.Server { + t.Helper() + app, err := community.Open(community.Config{ + DataDir: t.TempDir(), Backend: "http://127.0.0.1:1", PublicURL: "http://localhost:7780", Shared: e2eAccounts{}, + Work: work, BrokerKey: brokerKey, EngineURL: engineURL, Environment: "prod", + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { app.Close() }) + srv := httptest.NewServer(app) + t.Cleanup(srv.Close) + return srv +} + +func TestWorkerEndToEndThroughTheCommunityAppToTheEngine(t *testing.T) { + j, _ := workJoint(t) + ctx := context.Background() + moved, blocked := "http://pages.example.com/moved", "http://pages.example.com/blocked" + for _, u := range []string{moved, blocked} { + if err := j.ps.PushFrontier(ctx, u, 0, 1); err != nil { + t.Fatal(err) + } + if err := j.ps.FailFrontier(ctx, u, "http 403"); err != nil { + t.Fatal(err) + } + } + j.srv.work.svc.Feed(ctx) + + origin, _ := fixtureOrigin(t) + page, err := fetchAndExtract(ctx, originClient(origin), nil, moved, 0) + if err != nil { + t.Fatal(err) + } + workerB, netB := strings.Repeat("b", 32), strings.Repeat("2", 32) + claim := j.do("community-broker", "POST", "/v1/work/claim", map[string]any{"worker": workerB, "net": netB, "max": 2}) + j.want(claim, 200, "") + var movedLease string + for _, l := range claim.body["leases"].([]any) { + if l.(map[string]any)["url"] == moved { + movedLease = l.(map[string]any)["lease_id"].(string) + } + } + if movedLease == "" { + t.Fatalf("no lease for the redirecting page: %s", claim.raw) + } + j.want(j.do("community-broker", "POST", "/v1/work/submit", submitBodyAs(movedLease, workerB, netB, page.FinalURL, page.Title, page.Text)), 200, "") + + app := openApp(t, "http://"+j.addr, j.keys["community-broker"], true) + stdout, stderr, err := runWorkerCLI(t, app2fake(app), workerTestToken, "-worker", "-server", app.URL, "-max-pages", "2", "-concurrency", "2") + if err != nil { + t.Fatalf("%v\n%s\n%s", err, stdout, stderr) + } + var submitted, reported, summary map[string]any + for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") { + var e map[string]any + if err := json.Unmarshal([]byte(line), &e); err != nil { + t.Fatalf("stdout line %q", line) + } + switch e["event"] { + case "submitted": + submitted = e + case "reported": + reported = e + case "summary": + summary = e + } + } + if submitted["verdict"] != "verified" || submitted["agreeing_count"] != float64(2) { + t.Fatalf("submitted: %v\n%s", submitted, stdout) + } + if reported["http_status"] != float64(403) { + t.Fatalf("reported: %v\n%s", reported, stdout) + } + if summary["claimed"] != float64(2) || summary["submitted"] != float64(1) || summary["reported"] != float64(1) || summary["stop_reason"] != "max_pages" { + t.Fatalf("summary: %v", summary) + } + + j.srv.work.svc.Sweep(ctx) + settled := j.do("community-broker", "GET", "/v1/work/settled?after=0&limit=10", nil) + j.want(settled, 200, "") + events := settled.body["events"].([]any) + if len(events) != 1 || events[0].(map[string]any)["verdict"] != "verified" || len(events[0].(map[string]any)["workers"].([]any)) != 2 { + t.Fatalf("settled: %s", settled.raw) + } + contents := j.do("synth-prod", "POST", "/v1/contents", map[string]any{"urls": []string{moved}}) + j.want(contents, 200, "") + item := contents.body["results"].([]any)[0].(map[string]any) + if item["found"] != true || item["source"] != "attested" || item["verified_by"] != "quorum" || item["text"] != strings.Join(strings.Fields(page.Text), " ") { + t.Fatalf("contents: %s", contents.raw) + } +} + +func TestWorkerAgainstAnAppWithWorkSwitchedOffStopsWithAClearMessage(t *testing.T) { + j, _ := workJoint(t) + app := openApp(t, "http://"+j.addr, j.keys["community-broker"], false) + stdout, _, err := runWorkerCLI(t, app2fake(app), workerTestToken, "-worker", "-server", app.URL, "-max-pages", "1") + if err == nil || !strings.Contains(err.Error(), "does not offer worker mode") || !strings.Contains(stdout, `"event":"summary"`) { + t.Fatalf("%v %q", err, stdout) + } +} + +// app2fake wraps a real app server in the fakeApp shape runWorkerCLI expects. +func app2fake(srv *httptest.Server) *fakeApp { + return &fakeApp{srv: srv} +} From a82c4539c5dd95099299f0a4aad22101cf4395b6 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Thu, 15 Oct 2026 21:30:09 +0300 Subject: [PATCH 108/121] fix(cosift): the worker parses a PDF in a child process, as the server copy does --- cmd/cosift/fetch_extract.go | 5 ++- cmd/cosift/fetch_extract_pdf_test.go | 53 ++++++++++++++++++++++++++++ internal/crawler/fetchone.go | 7 +++- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 cmd/cosift/fetch_extract_pdf_test.go diff --git a/cmd/cosift/fetch_extract.go b/cmd/cosift/fetch_extract.go index f37c35d..c9212c7 100644 --- a/cmd/cosift/fetch_extract.go +++ b/cmd/cosift/fetch_extract.go @@ -46,7 +46,10 @@ func fetchAndExtract(ctx context.Context, client *http.Client, header http.Heade return nil, err } finalURL := res.Request.URL.String() - parsed, err := crawler.ExtractBody(res.Header.Get("Content-Type"), body, finalURL) + parsePDF := func(b []byte, u string) (*crawler.ParsedDoc, error) { + return crawler.ParsePDFSandboxed(ctx, b, u, "", 0) + } + parsed, err := crawler.ExtractBodyWith(res.Header.Get("Content-Type"), body, finalURL, parsePDF) if err != nil { return nil, err } diff --git a/cmd/cosift/fetch_extract_pdf_test.go b/cmd/cosift/fetch_extract_pdf_test.go new file mode 100644 index 0000000..f7b73a8 --- /dev/null +++ b/cmd/cosift/fetch_extract_pdf_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/pilot-protocol/cosift/internal/crawler" +) + +func TestMain(m *testing.M) { + if len(os.Args) > 1 && os.Args[1] == "parse-pdf" { + crawler.ParsePDFChild(os.Stdin, os.Stdout) + return + } + os.Exit(m.Run()) +} + +// nestedPDF is a valid PDF whose page tree is n nested arrays, which overflows the parser's stack. +func nestedPDF(n int) []byte { + objs := []string{ + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n", + "2 0 obj\n<< /Type /Pages /Count 1 /Kids " + strings.Repeat("[", n) + " >>\nendobj\n", + } + out := "%PDF-1.4\n" + var offsets []int + for _, o := range objs { + offsets = append(offsets, len(out)) + out += o + } + xref := len(out) + out += fmt.Sprintf("xref\n0 %d\n0000000000 65535 f \n", len(objs)+1) + for _, off := range offsets { + out += fmt.Sprintf("%010d 00000 n \n", off) + } + return []byte(out + fmt.Sprintf("trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xref)) +} + +func TestFetchAndExtractSurvivesAPDFThatOverflowsTheParserStack(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + w.Write(nestedPDF(2<<20 - 200)) + })) + t.Cleanup(srv.Close) + _, err := fetchAndExtract(context.Background(), srv.Client(), crawler.BrowserHeaders(), srv.URL+"/hostile.pdf", workerBody) + if err == nil { + t.Fatal("a hostile pdf was extracted") + } +} diff --git a/internal/crawler/fetchone.go b/internal/crawler/fetchone.go index 3de05b3..490ca95 100644 --- a/internal/crawler/fetchone.go +++ b/internal/crawler/fetchone.go @@ -83,13 +83,18 @@ func FetchOne(ctx context.Context, client *http.Client, userAgent, rawURL string // ExtractBody turns a fetched body into the parsed page FetchOne returns; binary content types are refused. func ExtractBody(contentType string, body []byte, finalURL string) (*ParsedDoc, error) { + return ExtractBodyWith(contentType, body, finalURL, ParsePDF) +} + +// ExtractBodyWith is ExtractBody with the PDF parser supplied by the caller, so a PDF can be parsed out of process. +func ExtractBodyWith(contentType string, body []byte, finalURL string, parsePDF func([]byte, string) (*ParsedDoc, error)) (*ParsedDoc, error) { if err := checkContentType(contentType); err != nil { return nil, err } var parsed *ParsedDoc var err error if strings.Contains(contentType, "pdf") { - parsed, err = ParsePDF(body, finalURL) + parsed, err = parsePDF(body, finalURL) } else { parsed, err = Parse(body, finalURL) } From 1e2a40ca94d5d503218847853b8775e70a69e197 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 12:08:13 +0300 Subject: [PATCH 109/121] test(cosift): take the current shared contribution vectors, pin them and pin the award vector groups to them The pinned hash was the stale copy's own, so a drift could never fail it. The award vectors are a subset of the shared groups: the test now compares them with the pinned copy. --- .../testdata/contribution-vectors-v1.json | 95 ++++++++++++++++++- internal/community/vectors_test.go | 28 +++++- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/internal/community/testdata/contribution-vectors-v1.json b/internal/community/testdata/contribution-vectors-v1.json index 7f6707c..8148587 100644 --- a/internal/community/testdata/contribution-vectors-v1.json +++ b/internal/community/testdata/contribution-vectors-v1.json @@ -1,5 +1,5 @@ { - "about": "Shared contribution vectors v1 (CT 11-12): the accept and refuse cases of the submission checks with their expected {code, field}, the handle rules, the award ids and amounts, the one award per contribution, the royalty arithmetic, the clawback matching, the work verification table (a quorum never skips the server fetch for a page the server can fetch), the ledger clamp, the read-marker keys, submission_sha and the style hash. The cosift-articles intake (A2), the awards and rewards code (A3) and the MCP (M2) load this file; tests/test_contribution_vectors.py checks its structure and its consistency with CT 11.8. Strings are ASCII-escaped JSON so no editor can normalise them.", + "about": "Shared contribution vectors v1 (CT 11-12): the accept and refuse cases of the submission checks with their expected {code, field}, the handle rules, the award ids and amounts, the one award per contribution, the royalty arithmetic, the clawback matching, the work verification table (a quorum never skips the server fetch for a page the server can fetch), the parts and agreed text of an errored quorum (work_agreed_text), the ledger clamp, the read-marker keys, submission_sha and the style hash. The cosift-articles intake (A2), the awards and rewards code (A3) and the MCP (M2) load this file; tests/test_contribution_vectors.py checks its structure and its consistency with CT 11.8. Strings are ASCII-escaped JSON so no editor can normalise them.", "version": 1, "style": {"style_version": 1, "file": "docs/public/style-v1.md", "sha256": "7043a389e85487cbd6c2798fcc5bfce3cd804131d7d0f4304da81089291d6d27"}, "language": {"min_latin_letter_ratio": 0.9, "min_stopword_ratio": 0.15, "stopwords": ["the", "of", "and", "to", "a", "in", "is", "that", "for", "it", "as", "was", "with", "be", "by", "on", "are", "or", "this", "from", "at", "which", "but", "have", "an", "had", "they", "were", "their", "one", "all", "can", "has", "there", "been", "if", "more", "when", "will", "would"], "token_cases": [{"id": "plain", "text": "The tide, the moon.", "tokens": ["the", "tide", "the", "moon"]}, {"id": "apostrophe-inside-a-word", "text": "Don't rock'n'roll", "tokens": ["don't", "rock'n'roll"]}, {"id": "curly-apostrophe-reads-as-straight", "text": "Don\u2019t", "tokens": ["don't"]}, {"id": "leading-trailing-and-doubled-apostrophes-end-a-token", "text": "'tis dogs' a''b ''", "tokens": ["tis", "dogs", "a", "b"]}, {"id": "digits-and-symbols-end-a-token", "text": "x2y 3d x\u00b2y a_b", "tokens": ["x", "y", "d", "x", "y", "a", "b"]}, {"id": "nfc-first", "text": "cafe\u0301 caf\u00e9", "tokens": ["caf\u00e9", "caf\u00e9"]}, {"id": "no-letters-no-tokens", "text": "123 !? ' \u2019", "tokens": []}], "ratio_cases": [{"id": "stopwords-exactly-15-percent", "text": "the the the tide tide tide tide tide tide tide tide tide tide tide tide tide tide tide tide tide", "expect": "accepted"}, {"id": "stopwords-just-below-15-percent", "text": "the the the tide tide tide tide tide tide tide tide tide tide tide tide tide tide tide tide tide tide", "expect": "not_english"}, {"id": "latin-letters-exactly-90-percent", "text": "the the the the the the the the the the the the the the the the the the \u043f\u0440\u0438\u0432\u0435\u0442", "expect": "accepted"}, {"id": "latin-letters-just-below-90-percent", "text": "the the the the the the the the the the the the the the the the the the \u043f\u0440\u0438\u0432\u0435\u0442\u044b", "expect": "not_english"}, {"id": "accented-latin-counts-as-latin", "text": "the the the the the the the the the the the the the the the the the the caf\u00e9s \u1ebdx", "expect": "accepted"}, {"id": "no-letters-fails", "text": "123 456 789", "expect": "not_english"}]}, @@ -141,6 +141,10 @@ {"id": "refuse-citation-url-port", "stage": "sync", "patch": [{"op": "set", "path": "citations[1].url", "value": "https://oceans.example.com:8443/rance-barrage"}], "expect": {"status": "refused", "code": "url_invalid", "field": "citations[1].url"}}, {"id": "refuse-citation-url-http-on-443", "stage": "sync", "patch": [{"op": "set", "path": "citations[1].url", "value": "http://oceans.example.com:443/rance-barrage"}], "expect": {"status": "refused", "code": "url_invalid", "field": "citations[1].url"}}, {"id": "accept-citation-url-default-port", "stage": "sync", "patch": [{"op": "set", "path": "citations[1].url", "value": "https://oceans.example.com:443/rance-barrage"}], "expect": {"status": "accepted"}}, + {"id": "refuse-citation-url-trailing-dot-ip", "stage": "sync", "patch": [{"op": "set", "path": "citations[1].url", "value": "http://127.0.0.1./rance-barrage"}], "expect": {"status": "refused", "code": "url_invalid", "field": "citations[1].url"}}, + {"id": "refuse-citation-url-hex-ip", "stage": "sync", "patch": [{"op": "set", "path": "citations[1].url", "value": "http://0x7f.0.0.0x1/rance-barrage"}], "expect": {"status": "refused", "code": "url_invalid", "field": "citations[1].url"}}, + {"id": "refuse-citation-url-octal-ip", "stage": "sync", "patch": [{"op": "set", "path": "citations[1].url", "value": "http://0177.0.0.1/rance-barrage"}], "expect": {"status": "refused", "code": "url_invalid", "field": "citations[1].url"}}, + {"id": "refuse-citation-url-trailing-dot-name", "stage": "sync", "patch": [{"op": "set", "path": "citations[1].url", "value": "https://oceans.example.com./rance-barrage"}], "expect": {"status": "refused", "code": "url_invalid", "field": "citations[1].url"}}, {"id": "refuse-quote-empty", "stage": "sync", "patch": [{"op": "set", "path": "citations[0].quote", "value": ""}], "expect": {"status": "refused", "code": "quote_length", "field": "citations[0].quote"}}, {"id": "refuse-quote-501", "stage": "sync", "patch": [{"op": "set", "path": "citations[2].quote", "value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}], "expect": {"status": "refused", "code": "quote_length", "field": "citations[2].quote"}}, {"id": "refuse-quote-zero-width", "stage": "sync", "patch": [{"op": "set", "path": "citations[1].quote", "value": "The Rance\u200b tidal power station"}], "expect": {"status": "refused", "code": "char_class", "field": "citations[1].quote"}}, @@ -302,6 +306,14 @@ {"id": "suggest-http-on-443", "urls": ["http://example.org:443/a"], "expect": {"status": "refused", "code": "url_invalid", "field": "urls[0]"}}, {"id": "suggest-default-port", "urls": ["https://example.org:443/a"], "expect": {"status": "accepted"}}, {"id": "suggest-http-default-port", "urls": ["http://example.org:80/a"], "expect": {"status": "accepted"}}, + {"id": "suggest-trailing-dot-name", "urls": ["https://example.org./a"], "expect": {"status": "refused", "code": "url_invalid", "field": "urls[0]"}}, + {"id": "suggest-trailing-dot-ip", "urls": ["http://127.0.0.1./a"], "expect": {"status": "refused", "code": "url_invalid", "field": "urls[0]"}}, + {"id": "suggest-double-trailing-dot", "urls": ["https://example.org../a"], "expect": {"status": "refused", "code": "url_invalid", "field": "urls[0]"}}, + {"id": "suggest-hex-dotted-ip", "urls": ["http://0x7f.0.0.1/a"], "expect": {"status": "refused", "code": "url_invalid", "field": "urls[0]"}}, + {"id": "suggest-hex-last-label-ip", "urls": ["http://0x7f.0.0.0x1/a"], "expect": {"status": "refused", "code": "url_invalid", "field": "urls[0]"}}, + {"id": "suggest-hex-number-ip", "urls": ["http://0x7f000001/a"], "expect": {"status": "refused", "code": "url_invalid", "field": "urls[0]"}}, + {"id": "suggest-octal-ip", "urls": ["http://0177.0.0.1/a"], "expect": {"status": "refused", "code": "url_invalid", "field": "urls[0]"}}, + {"id": "suggest-short-dotted-ip", "urls": ["http://127.1/a"], "expect": {"status": "refused", "code": "url_invalid", "field": "urls[0]"}}, {"id": "suggest-null", "urls": null, "expect": {"status": "refused", "code": "invalid_type", "field": "urls"}} ], "amounts": { @@ -403,7 +415,7 @@ {"id": "a-lost-contributions-award-is-reclaimed-once", "article_id": "01J9A3Q8B2C3D4E5F6G7H8J9K0", "awards": [{"award_id": "art:01J9A3Q7W4X9M3K5N6P8R0T2V4", "reason": "article", "cid": "0f1e2d3c4b5a6978", "ref": {"contribution_id": "01J9A3Q7W4X9M3K5N6P8R0T2V4", "article_id": "01J9A3Q8B2C3D4E5F6G7H8J9K0", "version": 1}, "status": "applied", "amount": 1000, "applied_delta": 1000}], "contribution_status": {"01J9A3Q7W4X9M3K5N6P8R0T2V4": "lost"}, "requests": [{"request_id": "01J9A3Q8B2C3D4E5F6G7H8J9K0_01J9B4R8C3D4E5F6G7H8J9K0M1", "action": "tombstone", "versions": "all", "cids": ["0f1e2d3c4b5a6978"]}, {"request_id": "01J9A3Q8B2C3D4E5F6G7H8J9K0_01J9B4R8C3D4E5F6G7H8J9K0M2", "action": "tombstone", "versions": "all", "cids": ["0f1e2d3c4b5a6978"]}], "expect": {"in_order": [{"0f1e2d3c4b5a6978": 1000}, {}], "reversed": [{"0f1e2d3c4b5a6978": 1000}, {}], "total_by_cid": {"0f1e2d3c4b5a6978": 1000}, "moved_total_by_cid": {}}} ], "work_verification": { - "about": "WORK 4.3, 4.4: events are attest (an agreeing attestation from a pair distinct from every earlier one), window_end, refetch_ok (the server fetched a copy within simhash_max of a group) and refetch_fail. verified_by is whose text is indexed; frontier_row is the pool entry class frontier or errored row, null for pushed. state, verified_by, indexed_text and frontier_row describe the end of the path on the assumption that moderation (WORK 4.5) allows the page and IndexAttested (WORK 6.4) succeeds: a deferral, a rejection or an index failure leaves indexed_text null and the row as it was, and WORK 4.1 completes the row only after IndexAttested.", + "about": "WORK 4.3, 4.4: events are attest (an agreeing attestation from a pair distinct from every earlier one; on an errored page it also has the anchor's title and lang and an agreed text of at least 60 percent of both sides, WORK 4.3 and 6.5, which the work_agreed_text cases pin), window_end, refetch_ok (the server fetched a copy within simhash_max of a group) and refetch_fail. verified_by is whose text is indexed (for quorum, the agreed text of WORK 6.5); frontier_row is the pool entry class frontier or errored row, null for pushed. state, verified_by, indexed_text and frontier_row describe the end of the path on the assumption that moderation (WORK 4.5) allows the page and IndexAttested (WORK 6.4) succeeds: a deferral, a rejection or an index failure leaves indexed_text null and the row as it was, and WORK 4.1 completes the row only after IndexAttested.", "cases": [ {"id": "frontier-one-attestation-waits-for-the-window", "class": "frontier", "events": ["attest"], "expect": {"state": "awaiting", "verified_by": null, "indexed_text": null, "frontier_row": "queued", "refetch_due": false}}, {"id": "frontier-quorum-waits-for-the-server", "class": "frontier", "events": ["attest", "attest"], "expect": {"state": "awaiting", "verified_by": null, "indexed_text": null, "frontier_row": "queued", "refetch_due": true}}, @@ -419,6 +431,85 @@ {"id": "errored-a-failed-fetch-then-a-quorum", "class": "errored", "events": ["attest", "window_end", "refetch_fail", "attest"], "expect": {"state": "verified", "verified_by": "quorum", "indexed_text": "quorum", "frontier_row": "done", "refetch_due": false}} ] }, + "work_agreed_text": { + "about": "WORK 4.3 and 6.5. splitter: the cut of a text into parts (part_words are the words of each part, parts their 16-digit hashes). title_sha: the hash of a title, over its words joined by single spaces. alignment: the in-order walk of WORK 6.5 on lists of part symbols, a being the anchor's and c the completing submission's; a_total and c_total, when present, are the numbers of parts before the cap; matches are the [i, j] pairs; agreed lists the completer's symbols with the gap marker where a gap is. cases: the attestation of one account agreed against an anchor, an errored page's first attestation; expect is what the reference implementation of tests/test_contribution_vectors.py computes: simhashes are the two simhashes (WORK 6.2, anchor first) and distance their Hamming distance, fails lists every failing condition of WORK 4.3 in the order simhash (distance above simhash_max), title (the normalised titles differ), lang, floor_attestation (10 x matched_bytes below 6 x the attestation's text bytes), floor_anchor (the same against the anchor's text bytes) and agreed_minimum (agreed_bytes below agreed_min_bytes), agree is true when none fails, matches are the [i, j] pairs of the walk, matched_bytes is M, the bytes of the matched parts, agreed_text and agreed_bytes are the agreed text of the attestation against the anchor (empty when nothing matched), title is the normalised title and content_hash the sha256 of the agreed text (both present when they agree). Worker and net distinctness is not in these vectors. cap: a text of more parts than parts_max.", + "parameters": {"simhash_max": 6, "parts_max": 2048, "boundary_min_words": 8, "part_max_words": 96, "boundary_modulus": 32, "floor_percent": 60, "agreed_min_bytes": 80}, + "splitter": [ + {"id": "one-part-under-eight-words", "text": "Tidal power turns the sea into electricity", "part_words": [7], "parts": ["cd73ec2e4f1cfd07"]}, + {"id": "english-page", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "part_words": [48, 43, 12, 50, 52, 50, 71, 75, 37, 23, 20], "parts": ["2a9e415a6d64e4f7", "380d61bf8f9d14b5", "3aa2faa357909331", "d597e8ade31124b7", "0d1163fead380d6d", "678f80e98e6101b0", "681851cf1606f188", "25f9789cf6261b87", "edfc318df846fccc", "48dc7c3602c2c1bb", "c0843de7cb3f3227"]}, + {"id": "no-boundary-until-the-96-word-cap", "text": "tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock tick tock", "part_words": [96, 96, 8], "parts": ["c13547a841d441b4", "c13547a841d441b4", "ec11df81b659add4"]}, + {"id": "a-96-word-part-in-prose", "text": "Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service.", "part_words": [41, 54, 19, 16, 64, 27, 96, 34], "parts": ["7cb2e41be02ab2a8", "42be4d876ab4362d", "840ee3a10118e287", "b4a12849b2cac786", "f0a3c09cc0356e61", "410ea2decef1e9f1", "795ccd4352749e8b", "1b5aa71cf7a6c45f"]}, + {"id": "unicode-french-with-no-break-spaces", "text": "L'\u00e9nergie mar\u00e9motrice transforme le va-et-vient des mar\u00e9es en \u00e9lectricit\u00e9\u00a0: c'est la plus r\u00e9guli\u00e8re des \u00e9nergies de la mer. La Lune et le Soleil attirent les oc\u00e9ans, et dans un estuaire \u00e9troit la diff\u00e9rence entre la haute et la basse mer peut d\u00e9passer douze m\u00e8tres. L'usine de la Rance, en Bretagne, a \u00e9t\u00e9 mise en service en 1966. Elle compte vingt-quatre turbines et produit chaque ann\u00e9e environ cinq cent soixante gigawattheures\u00a0; ses ing\u00e9nieurs ont d\u00fb r\u00e9soudre des probl\u00e8mes de corrosion, de sable et de poissons migrateurs, puis surveiller pendant des d\u00e9cennies l'\u00e9volution de la vasi\u00e8re situ\u00e9e en amont. Les hydroliennes fonctionnent autrement\u00a0: elles sont pos\u00e9es dans le courant, comme des \u00e9oliennes sous l'eau. L'eau \u00e9tant environ huit cents fois plus dense que l'air, un courant lent suffit pour entra\u00eener des pales bien plus petites. Les premiers parcs, en \u00c9cosse et au Raz Blanchard, livrent d\u00e9j\u00e0 de l'\u00e9lectricit\u00e9 au r\u00e9seau. Reste la question du co\u00fbt\u00a0: l'\u00e9quipement marin doit r\u00e9sister \u00e0 la temp\u00eate, au sel et aux courants violents, et chaque intervention exige un navire et une fen\u00eatre m\u00e9t\u00e9o favorable. Les partisans r\u00e9pondent que la pr\u00e9visibilit\u00e9 des mar\u00e9es facilite la gestion du r\u00e9seau\u00a0; les \u00e9cologistes demandent quant \u00e0 eux que les projets soient jug\u00e9s sur leur effet r\u00e9el sur les oiseaux, les poissons et les s\u00e9diments.", "part_words": [28, 25, 26, 21, 13, 77, 28], "parts": ["84c13f9d301a8344", "6d252612bc9eba70", "04caa264c1c3c2bb", "4fb25b3e9c74752f", "11a5e54e7457e258", "dfab5b0672347835", "260719ba416e2bd6"]}, + {"id": "a-hash-boundary-before-the-eighth-word-is-ignored", "text": "Each morning the pilots found quiet water in the bay and then steered the ferry out past the breakwater before the tide turned and the first cargo boats came in.", "part_words": [10, 20], "parts": ["9d8450a3b0a3c84e", "649085fcf6843bbb"]}, + {"id": "unicode-lower-case-decides-the-boundary", "text": "Every morning the ferries now arrive from \u0130zmir \u2013 Greek pupils still recite the poem since \u039f\u0394\u03a5\u03a3\u03a3\u0395\u0391\u03a3 \u2013 \u041f\u0440\u0438\u0432\u0435\u0442 \u043c\u0438\u0440 \u4f60\u597d\u4e16\u754c and a \ud83d\ude00 close the page", "part_words": [8, 9, 10], "parts": ["c6f24bbc164c8c11", "a0c13da6a6200a25", "3ebc9ad249f70fd0"]}, + {"id": "unicode-white-space-and-control-characters", "text": "alpha\tbeta\ngamma\r\ndelta\u0085epsilon\u00a0zeta\u2003eta\u3000theta\u2028iota kappa\u001flambda mu\u200bnu xi omicron pi rho sigma tau upsilon phi chi psi omega", "part_words": [22], "parts": ["bc11c575f2184cfc"]}, + {"id": "case-only-lower", "text": "tidal power turns the regular rise and fall of the sea into electricity. the moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. a power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. the oldest design is the barrage, a low dam with gates and turbines set into it. at high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. the rance station in brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. its builders also had to manage the silt, the salt and the fish that pass through the estuary.", "part_words": [48, 43, 12, 45], "parts": ["51303809e699dbe9", "3146453e2a285f80", "3aa2faa357909331", "f125d5f1f9572a85"]}, + {"id": "case-only-upper", "text": "TIDAL POWER TURNS THE REGULAR RISE AND FALL OF THE SEA INTO ELECTRICITY. THE MOON AND THE SUN PULL ON THE OCEANS, AND WHERE A NARROW BAY OR ESTUARY FUNNELS THE WATER, THE DIFFERENCE BETWEEN HIGH AND LOW TIDE CAN REACH MANY METRES. A POWER STATION BUILT ACROSS SUCH A BAY CAN CAPTURE THAT DIFFERENCE TWICE A DAY, IN EVERY SEASON, ON A TIMETABLE THAT ENGINEERS CAN PUBLISH YEARS AHEAD. THE OLDEST DESIGN IS THE BARRAGE, A LOW DAM WITH GATES AND TURBINES SET INTO IT. AT HIGH TIDE THE GATES OPEN AND THE BAY FILLS; AT LOW TIDE THE WATER IS RELEASED THROUGH THE TURBINES AND THE GENERATORS SPIN. THE RANCE STATION IN BRITTANY, WHICH OPENED IN 1966, USES TWENTY-FOUR TURBINES AND STILL SUPPLIES SEVERAL HUNDRED GIGAWATT-HOURS EVERY YEAR. ITS BUILDERS ALSO HAD TO MANAGE THE SILT, THE SALT AND THE FISH THAT PASS THROUGH THE ESTUARY.", "part_words": [48, 43, 12, 45], "parts": ["84f76c7498f19fd8", "bfa0397feae4bdbb", "5c571ef4fcd15e95", "69e78ce870f4c4e2"]} + ], + "title_sha": [ + {"id": "plain", "title": "Tidal power", "sha": "bb5499ba9df8b525"}, + {"id": "white-space-collapsed", "title": " Tidal \t power\n", "sha": "bb5499ba9df8b525"}, + {"id": "unicode-spaces", "title": "Tidal\u00a0\u00a0power\u3000", "sha": "bb5499ba9df8b525"}, + {"id": "case-differs", "title": "Tidal Power", "sha": "e769f0d0ec5c0e1d"}, + {"id": "empty", "title": "", "sha": "e3b0c44298fc1c14"}, + {"id": "unicode", "title": "Mar\u00e9es et \u00e9nergie", "sha": "65752ef8c7395403"} + ], + "alignment": [ + {"id": "identical", "a": ["a", "b", "c"], "c": ["a", "b", "c"], "matches": [[0, 0], [1, 1], [2, 2]], "agreed": ["a", "b", "c"]}, + {"id": "completer-omits-the-middle", "a": ["a", "b", "c", "d"], "c": ["a", "b", "d"], "matches": [[0, 0], [1, 1], [3, 2]], "agreed": ["a", "b", "\u2026", "d"]}, + {"id": "completer-omits-the-first", "a": ["a", "b", "c"], "c": ["b", "c"], "matches": [[1, 0], [2, 1]], "agreed": ["\u2026", "b", "c"]}, + {"id": "completer-omits-the-last", "a": ["a", "b", "c"], "c": ["a", "b"], "matches": [[0, 0], [1, 1]], "agreed": ["a", "b", "\u2026"]}, + {"id": "anchor-omits-the-middle", "a": ["a", "b", "d"], "c": ["a", "b", "c", "d"], "matches": [[0, 0], [1, 1], [2, 3]], "agreed": ["a", "b", "\u2026", "d"]}, + {"id": "anchor-omits-the-last", "a": ["a", "b"], "c": ["a", "b", "c"], "matches": [[0, 0], [1, 1]], "agreed": ["a", "b", "\u2026"]}, + {"id": "completer-adds-at-both-ends", "a": ["b", "c"], "c": ["a", "b", "c", "d"], "matches": [[0, 1], [1, 2]], "agreed": ["\u2026", "b", "c", "\u2026"]}, + {"id": "completer-repeats-an-honest-part", "a": ["a", "b", "c", "d", "e"], "c": ["a", "a", "a", "a", "b"], "matches": [[0, 0], [1, 4]], "agreed": ["a", "\u2026", "b", "\u2026"]}, + {"id": "a-repeat-in-place-keeps-one-copy", "a": ["a", "b", "c"], "c": ["a", "b", "b", "b", "c"], "matches": [[0, 0], [1, 1], [2, 4]], "agreed": ["a", "b", "\u2026", "c"]}, + {"id": "a-swap-is-a-tie-and-the-anchor-advances", "a": ["x", "y"], "c": ["y", "x"], "matches": [[1, 0]], "agreed": ["\u2026", "y", "\u2026"]}, + {"id": "a-block-moved-to-the-front", "a": ["a", "b", "c", "d", "e", "f"], "c": ["e", "f", "a", "b", "c", "d"], "matches": [[0, 2], [1, 3], [2, 4], [3, 5]], "agreed": ["\u2026", "a", "b", "c", "d", "\u2026"]}, + {"id": "nothing-in-common", "a": ["a", "b", "c"], "c": ["x", "y", "z"], "matches": [], "agreed": []}, + {"id": "interleaved", "a": ["a", "c", "e", "g", "i"], "c": ["a", "b", "c", "d", "e", "f", "g", "h", "i"], "matches": [[0, 0], [1, 2], [2, 4], [3, 6], [4, 8]], "agreed": ["a", "\u2026", "c", "\u2026", "e", "\u2026", "g", "\u2026", "i"]}, + {"id": "reversed", "a": ["a", "b", "c", "d", "e"], "c": ["e", "d", "c", "b", "a"], "matches": [[4, 0]], "agreed": ["\u2026", "e", "\u2026"]}, + {"id": "ties-inside-repeats", "a": ["a", "b", "a", "b"], "c": ["b", "a", "b", "a"], "matches": [[1, 0], [2, 1], [3, 2]], "agreed": ["\u2026", "b", "a", "b", "\u2026"]}, + {"id": "a-longer-tie", "a": ["a", "b", "c", "a", "b", "c"], "c": ["c", "b", "a", "c", "b", "a"], "matches": [[2, 0], [4, 1], [5, 3]], "agreed": ["\u2026", "c", "\u2026", "b", "\u2026", "c", "\u2026"]}, + {"id": "the-anchor-has-parts-beyond-the-cap", "a": ["a", "b", "c"], "c": ["a", "b", "c"], "a_total": 5, "matches": [[0, 0], [1, 1], [2, 2]], "agreed": ["a", "b", "c", "\u2026"]}, + {"id": "the-completer-has-parts-beyond-the-cap", "a": ["a", "b", "c"], "c": ["a", "b", "c"], "c_total": 5, "matches": [[0, 0], [1, 1], [2, 2]], "agreed": ["a", "b", "c", "\u2026"]}, + {"id": "both-have-parts-beyond-the-cap-and-a-gap-inside", "a": ["a", "b", "d"], "c": ["a", "c", "d"], "a_total": 4, "c_total": 4, "matches": [[0, 0], [2, 2]], "agreed": ["a", "\u2026", "d", "\u2026"]} + ], + "cap": {"about": "Part k is the eight words q{k}a to q{k}g and then q{k}h{s}, s the least integer from 0 for which the boundary hash of q{k}g and q{k}h{s} is 0 mod 32; the text is all parts joined by single spaces. Its 2,051 parts are cut at the 2,048th for hashing and agreement: the three after it are never agreed and count as a trailing gap, whichever side has them. The text agrees with itself: matched is the number of matches and matched_bytes is M.", "parts": 2051, "considered": 2048, "text_bytes": 109421, "text_sha256": "dbe14944396e4259c682b639070d4cc1a254ad39139761cd1ce459e5553cd106", "first": "098e1b5f45910a92", "last_considered": "ece4076b4daf8ff7", "first_beyond": "84ed4b35e7baa539", "matched": 2048, "matched_bytes": 107200, "agreed_bytes": 109251, "tail_copies": {"about": "The same text with parts 2,049 to 2,051 replaced by copies of parts 1 to 3, so their hashes are in the anchor's list: the cap still drops them, and the agreed text is the same.", "text_bytes": 109348, "text_sha256": "b965c95b8a0039821976d874b389d9f1a394200c830c7f7050ae7a497645ce69", "matched": 2048, "agreed_bytes": 109251}}, + "cases": [ + {"id": "identical-texts", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["572df4c50f5b09cd", "572df4c50f5b09cd"], "distance": 0, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10]], "matched_bytes": 2804, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2814, "title": "Tidal power", "content_hash": "6074783a206d06bdd46dafaec09bf772ad01a3aea472e468c9c3b226b4a8b67a"}}, + {"id": "same-words-different-white-space", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal\u00a0power ", "lang": "en", "text": "Tidal power turns the regular\trise and fall of the\u00a0sea into electricity. The moon\u3000and the sun pull on\nthe oceans, and where a\u2003narrow bay or estuary funnels the water, the difference between\thigh and low tide can\u00a0reach many metres. A power\u3000station built across such a\nbay can capture that difference\u2003twice a day, in every season, on a timetable that\tengineers can publish years ahead.\u00a0The oldest design is the\u3000barrage, a low dam with\ngates and turbines set into\u2003it. At high tide the gates open and the bay\tfills; at low tide the\u00a0water is released through the\u3000turbines and the generators spin.\nThe Rance station in Brittany,\u2003which opened in 1966, uses twenty-four turbines and still supplies\tseveral hundred gigawatt-hours every year.\u00a0Its builders also had to\u3000manage the silt, the salt\nand the fish that pass\u2003through the estuary. A barrage changes the bay behind it.\tMudflats that were covered and\u00a0uncovered twice a day may\u3000stay wetter or drier than\nbefore, wading birds lose feeding\u2003grounds, and the flow of sediment shifts. Environmental reviews of\tlater proposals therefore weigh the\u00a0energy gained against the habitat\u3000altered, and several large schemes\nhave been shelved because the\u2003estimated damage outweighed the benefit. Tidal stream turbines work differently.\tInstead of a dam, they\u00a0stand in the open current\u3000like underwater wind turbines, and\nthe moving water turns the\u2003blades. Because water is about eight hundred times denser than\tair, a slow current carries\u00a0a great deal of energy,\u3000and the machines can be\nmuch smaller than a wind\u2003turbine of the same output. The MeyGen array in the\tPentland Firth, off the north\u00a0coast of Scotland, has delivered\u3000power to the grid since\n2017. Cost remains the main\u2003obstacle. Marine equipment must survive corrosion, storms and the strong\tcurrents that make a site\u00a0attractive in the first place,\u3000and every maintenance visit needs\na vessel and a calm\u2003window. Early projects have been expensive per unit of electricity,\talthough the price has fallen\u00a0as designs have been standardised\u3000and the first arrays have\ngathered operating data. Supporters point\u2003to predictability. Wind and sunshine vary with the weather, but\tthe tide table is known\u00a0long in advance, so operators\u3000and grid managers can plan\naround it. Tides in different\u2003places peak at different hours, so a handful of sites\tspread along a coast could\u00a0together provide a steadier supply\u3000than any one of them\nalone. Researchers are also studying\u2003tidal lagoons, which enclose a stretch of shoreline with a\tsea wall and generate power\u00a0as the water flows in\u3000and out of the pool.\nSupporters say a lagoon disturbs\u2003less of the open estuary than a full barrage, while\tcritics note that the walls\u00a0are long, costly and still\u3000change the local habitat. Several\npilot plans remain under discussion\u2003in Britain, China and South Korea."}, "expect": {"simhashes": ["572df4c50f5b09cd", "572df4c50f5b09cd"], "distance": 0, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10]], "matched_bytes": 2804, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2814, "title": "Tidal power", "content_hash": "6074783a206d06bdd46dafaec09bf772ad01a3aea472e468c9c3b226b4a8b67a"}}, + {"id": "title-differs-only-in-white-space", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": " Tidal \t power\n", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["572df4c50f5b09cd", "572df4c50f5b09cd"], "distance": 0, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10]], "matched_bytes": 2804, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2814, "title": "Tidal power", "content_hash": "6074783a206d06bdd46dafaec09bf772ad01a3aea472e468c9c3b226b4a8b67a"}}, + {"id": "rotated-advertisement-and-changed-timestamp", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. Sponsored: Compare marine insurance quotes in minutes and save up to thirty percent on cover for your boat this season. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Last updated 14 October 2026, 09:12 UTC."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. Sponsored: Book a harbour mooring for the summer, with fifteen percent off when you reserve before the end of the month. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Last updated 14 October 2026, 11:47 UTC."}, "expect": {"simhashes": ["572de4d50f5b09c9", "522df4d50f5b09c9"], "distance": 3, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [4, 5], [5, 6], [6, 7], [7, 8], [8, 9], [9, 10]], "matched_bytes": 2370, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released \u2026 behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are \u2026", "agreed_bytes": 2386, "title": "Tidal power", "content_hash": "88210a12d256c7c26b3a7a38d84ab62d4609e0c391ff080b91a2846597ba3607"}}, + {"id": "injected-paragraph", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Independent engineers now warn that the Rance barrage is unsafe and will be closed next year, so investors are advised to sell their marine energy holdings. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["572df4c50f5b09cd", "532df4c50f4b09c9"], "distance": 3, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [7, 8], [8, 9], [9, 10], [10, 11]], "matched_bytes": 2397, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, \u2026 Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2410, "title": "Tidal power", "content_hash": "999f6e36aedece1b0505412a1f10bcffa589c0ce9adb1778f69de8fcc1b55f93"}}, + {"id": "paragraphs-injected-at-the-start-and-the-end", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Breaking: a leaked memo reveals that regulators have already decided to shut every tidal scheme in Europe. Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Readers who want to protect their savings should contact our partner broker today."}, "expect": {"simhashes": ["572df4c50f5b09cd", "5b2df4e50f5b09c1"], "distance": 5, "fails": [], "agree": true, "matches": [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9]], "matched_bytes": 2418, "agreed_text": "\u2026 such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are \u2026", "agreed_bytes": 2434, "title": "Tidal power", "content_hash": "2d2cdf599095df6d8125f09369c6f8d740f77018ff9220931e3a35d6f42d57ca"}}, + {"id": "entity-swapped-inside-one-part", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Normandy, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["572df4c50f5b09cd", "5b2df4d50f5b09c5"], "distance": 4, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10]], "matched_bytes": 2497, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released \u2026 behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2510, "title": "Tidal power", "content_hash": "dd1547da92f421c86b91588628faf09a9ba7701c458fceea162c136845d4708c"}}, + {"id": "long-page-entity-swap", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Solent, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "expect": {"simhashes": ["5a0dbcee8f4a19c1", "5a0dbcce8f4a1881"], "distance": 3, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [7, 7], [8, 8], [9, 9], [10, 10], [11, 11], [12, 12], [13, 13], [14, 14], [15, 15], [16, 16], [17, 17]], "matched_bytes": 4457, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, \u2026 Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service.", "agreed_bytes": 4477, "title": "Tidal power", "content_hash": "b52e33ba7e37a989db242f5100bbfc80ac8ab8f91bd76d797573864e74ca85be"}}, + {"id": "dishonest-anchor-removed-a-part", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["1f2df4c50f4b09c9", "572df4c50f5b09cd"], "distance": 4, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 6], [6, 7], [7, 8], [8, 9], [9, 10]], "matched_bytes": 2498, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the \u2026 and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2511, "title": "Tidal power", "content_hash": "c4971120c1691674a95cfaf96b0b654fd0ed18cc4c62d247e3645b7e6a2af4c3"}}, + {"id": "completer-omits-parts", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and"}, "expect": {"simhashes": ["5a0dbcee8f4a19c1", "5a0db4fe0d4a49c1"], "distance": 6, "fails": [], "agree": true, "matches": [[1, 0], [2, 1], [3, 2], [4, 3], [5, 4], [6, 5], [7, 6], [9, 7], [10, 8], [11, 9], [12, 10], [14, 11], [15, 12], [16, 13]], "matched_bytes": 4093, "agreed_text": "\u2026 such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together \u2026 the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things \u2026 system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and \u2026", "agreed_bytes": 4122, "title": "Tidal power", "content_hash": "3d085e5a8fe4ea198b18813a32bae09302150af6e7e55e65c01c6904dd0124b6"}}, + {"id": "completer-repeats-a-part", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released open and the bay fills; at low tide the water is released open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "expect": {"simhashes": ["5a0dbcee8f4a19c1", "5a0d9cee8f4b09c1"], "distance": 3, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 5], [4, 6], [5, 7], [6, 8], [7, 9], [8, 10], [9, 11], [10, 12], [11, 13], [12, 14], [13, 15], [14, 16], [15, 17], [16, 18], [17, 19]], "matched_bytes": 4864, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released \u2026 through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service.", "agreed_bytes": 4885, "title": "Tidal power", "content_hash": "8bf3e8cb2188195d5d1def4ea265d6f4c15200725def511e4b72d8de7d73da9f"}}, + {"id": "completer-repeats-honest-parts-to-clear-the-floors", "anchor": {"title": "Tidal power", "lang": "en", "text": "h0a h0b h0c h0d h0e h0f h0g h0h6 h1a h1b h1c h1d h1e h1f h1g h1h6 h2a h2b h2c h2d h2e h2f h2g h2h64 h3a h3b h3c h3d h3e h3f h3g h3h64 h4a h4b h4c h4d h4e h4f h4g h4h24 h5a h5b h5c h5d h5e h5f h5g h5h24 h6a h6b h6c h6d h6e h6f h6g h6h64 h7a h7b h7c h7d h7e h7f h7g h7h64 h8a h8b h8c h8d h8e h8f h8g h8h6 h9a h9b h9c h9d h9e h9f h9g h9h6 h10a h10b h10c h10d h10e h10f h10g h10h37 h11a h11b h11c h11d h11e h11f h11g h11h0 h12a h12b h12c h12d h12e h12f h12g h12h6 h13a h13b h13c h13d h13e h13f h13g h13h35 h14a h14b h14c h14d h14e h14f h14g h14h15 h15a h15b h15c h15d h15e h15f h15g h15h26 h16a h16b h16c h16d h16e h16f h16g h16h37 h17a h17b h17c h17d h17e h17f h17g h17h105 h18a h18b h18c h18d h18e h18f h18g h18h37 h19a h19b h19c h19d h19e h19f h19g h19h26 h20a h20b h20c h20d h20e h20f h20g h20h35 h21a h21b h21c h21d h21e h21f h21g h21h6 h22a h22b h22c h22d h22e h22f h22g h22h4 h23a h23b h23c h23d h23e h23f h23g h23h11 h24a h24b h24c h24d h24e h24f h24g h24h4 h25a h25b h25c h25d h25e h25f h25g h25h15 h26a h26b h26c h26d h26e h26f h26g h26h35 h27a h27b h27c h27d h27e h27f h27g h27h2 h28a h28b h28c h28d h28e h28f h28g h28h35 h29a h29b h29c h29d h29e h29f h29g h29h6 h30a h30b h30c h30d h30e h30f h30g h30h15 h31a h31b h31c h31d h31e h31f h31g h31h8 h32a h32b h32c h32d h32e h32f h32g h32h6 h33a h33b h33c h33d h33e h33f h33g h33h35 h34a h34b h34c h34d h34e h34f h34g h34h6 h35a h35b h35c h35d h35e h35f h35g h35h0 h36a h36b h36c h36d h36e h36f h36g h36h15 h37a h37b h37c h37d h37e h37f h37g h37h4 h38a h38b h38c h38d h38e h38f h38g h38h15 h39a h39b h39c h39d h39e h39f h39g h39h44 h40a h40b h40c h40d h40e h40f h40g h40h26 h41a h41b h41c h41d h41e h41f h41g h41h20 h42a h42b h42c h42d h42e h42f h42g h42h0 h43a h43b h43c h43d h43e h43f h43g h43h6 h44a h44b h44c h44d h44e h44f h44g h44h8 h45a h45b h45c h45d h45e h45f h45g h45h11 h46a h46b h46c h46d h46e h46f h46g h46h8 h47a h47b h47c h47d h47e h47f h47g h47h6 h48a h48b h48c h48d h48e h48f h48g h48h26 h49a h49b h49c h49d h49e h49f h49g h49h20 h50a h50b h50c h50d h50e h50f h50g h50h15 h51a h51b h51c h51d h51e h51f h51g h51h8 h52a h52b h52c h52d h52e h52f h52g h52h15 h53a h53b h53c h53d h53e h53f h53g h53h4 h54a h54b h54c h54d h54e h54f h54g h54h37 h55a h55b h55c h55d h55e h55f h55g h55h8 h56a h56b h56c h56d h56e h56f h56g h56h24 h57a h57b h57c h57d h57e h57f h57g h57h17 h58a h58b h58c h58d h58e h58f h58g h58h6 h59a h59b h59c h59d h59e h59f h59g h59h8 h60a h60b h60c h60d h60e h60f h60g h60h4 h61a h61b h61c h61d h61e h61f h61g h61h15 h62a h62b h62c h62d h62e h62f h62g h62h17 h63a h63b h63c h63d h63e h63f h63g h63h20 h64a h64b h64c h64d h64e h64f h64g h64h4 h65a h65b h65c h65d h65e h65f h65g h65h15 h66a h66b h66c h66d h66e h66f h66g h66h105 h67a h67b h67c h67d h67e h67f h67g h67h11 h68a h68b h68c h68d h68e h68f h68g h68h35 h69a h69b h69c h69d h69e h69f h69g h69h6 h70a h70b h70c h70d h70e h70f h70g h70h24 h71a h71b h71c h71d h71e h71f h71g h71h26 h72a h72b h72c h72d h72e h72f h72g h72h15 h73a h73b h73c h73d h73e h73f h73g h73h4 h74a h74b h74c h74d h74e h74f h74g h74h37 h75a h75b h75c h75d h75e h75f h75g h75h8 h76a h76b h76c h76d h76e h76f h76g h76h15 h77a h77b h77c h77d h77e h77f h77g h77h4 h78a h78b h78c h78d h78e h78f h78g h78h24 h79a h79b h79c h79d h79e h79f h79g h79h0 h80a h80b h80c h80d h80e h80f h80g h80h8 h81a h81b h81c h81d h81e h81f h81g h81h11 h82a h82b h82c h82d h82e h82f h82g h82h0 h83a h83b h83c h83d h83e h83f h83g h83h6 h84a h84b h84c h84d h84e h84f h84g h84h0 h85a h85b h85c h85d h85e h85f h85g h85h2 h86a h86b h86c h86d h86e h86f h86g h86h8 h87a h87b h87c h87d h87e h87f h87g h87h6 h88a h88b h88c h88d h88e h88f h88g h88h8 h89a h89b h89c h89d h89e h89f h89g h89h11 h90a h90b h90c h90d h90e h90f h90g h90h37 h91a h91b h91c h91d h91e h91f h91g h91h0 h92a h92b h92c h92d h92e h92f h92g h92h6 h93a h93b h93c h93d h93e h93f h93g h93h35 h94a h94b h94c h94d h94e h94f h94g h94h6 h95a h95b h95c h95d h95e h95f h95g h95h0 h96a h96b h96c h96d h96e h96f h96g h96h37 h97a h97b h97c h97d h97e h97f h97g h97h105 h98a h98b h98c h98d h98e h98f h98g h98h37 h99a h99b h99c h99d h99e h99f h99g h99h26"}, "attestation": {"title": "Tidal power", "lang": "en", "text": "h1a h1b h1c h1d h1e h1f h1g h1h6 h3a h3b h3c h3d h3e h3f h3g h3h64 h6a h6b h6c h6d h6e h6f h6g h6h64 h10a h10b h10c h10d h10e h10f h10g h10h37 h11a h11b h11c h11d h11e h11f h11g h11h0 h20a h20b h20c h20d h20e h20f h20g h20h35 h21a h21b h21c h21d h21e h21f h21g h21h6 h24a h24b h24c h24d h24e h24f h24g h24h4 h27a h27b h27c h27d h27e h27f h27g h27h2 h36a h36b h36c h36d h36e h36f h36g h36h15 h38a h38b h38c h38d h38e h38f h38g h38h15 h41a h41b h41c h41d h41e h41f h41g h41h20 h47a h47b h47c h47d h47e h47f h47g h47h6 h57a h57b h57c h57d h57e h57f h57g h57h17 h69a h69b h69c h69d h69e h69f h69g h69h6 h72a h72b h72c h72d h72e h72f h72g h72h15 h76a h76b h76c h76d h76e h76f h76g h76h15 h77a h77b h77c h77d h77e h77f h77g h77h4 h92a h92b h92c h92d h92e h92f h92g h92h6 h96a h96b h96c h96d h96e h96f h96g h96h37 h1a h1b h1c h1d h1e h1f h1g h1h6 h3a h3b h3c h3d h3e h3f h3g h3h64 h6a h6b h6c h6d h6e h6f h6g h6h64 h10a h10b h10c h10d h10e h10f h10g h10h37 h11a h11b h11c h11d h11e h11f h11g h11h0 h20a h20b h20c h20d h20e h20f h20g h20h35 h21a h21b h21c h21d h21e h21f h21g h21h6 h24a h24b h24c h24d h24e h24f h24g h24h4 h27a h27b h27c h27d h27e h27f h27g h27h2 h36a h36b h36c h36d h36e h36f h36g h36h15 h38a h38b h38c h38d h38e h38f h38g h38h15 h41a h41b h41c h41d h41e h41f h41g h41h20 h47a h47b h47c h47d h47e h47f h47g h47h6 h57a h57b h57c h57d h57e h57f h57g h57h17 h69a h69b h69c h69d h69e h69f h69g h69h6 h72a h72b h72c h72d h72e h72f h72g h72h15 h76a h76b h76c h76d h76e h76f h76g h76h15 h77a h77b h77c h77d h77e h77f h77g h77h4 h92a h92b h92c h92d h92e h92f h92g h92h6 h96a h96b h96c h96d h96e h96f h96g h96h37 h1a h1b h1c h1d h1e h1f h1g h1h6 h3a h3b h3c h3d h3e h3f h3g h3h64 h6a h6b h6c h6d h6e h6f h6g h6h64 h10a h10b h10c h10d h10e h10f h10g h10h37 h11a h11b h11c h11d h11e h11f h11g h11h0 h20a h20b h20c h20d h20e h20f h20g h20h35 h21a h21b h21c h21d h21e h21f h21g h21h6 h24a h24b h24c h24d h24e h24f h24g h24h4 h27a h27b h27c h27d h27e h27f h27g h27h2 h36a h36b h36c h36d h36e h36f h36g h36h15 h38a h38b h38c h38d h38e h38f h38g h38h15 h41a h41b h41c h41d h41e h41f h41g h41h20 h47a h47b h47c h47d h47e h47f h47g h47h6 h57a h57b h57c h57d h57e h57f h57g h57h17 h69a h69b h69c h69d h69e h69f h69g h69h6 h72a h72b h72c h72d h72e h72f h72g h72h15 h76a h76b h76c h76d h76e h76f h76g h76h15 h77a h77b h77c h77d h77e h77f h77g h77h4 h92a h92b h92c h92d h92e h92f h92g h92h6 h96a h96b h96c h96d h96e h96f h96g h96h37 h1a h1b h1c h1d h1e h1f h1g h1h6 h3a h3b h3c h3d h3e h3f h3g h3h64 h6a h6b h6c h6d h6e h6f h6g h6h64 h10a h10b h10c h10d h10e h10f h10g h10h37 h11a h11b h11c h11d h11e h11f h11g h11h0 h20a h20b h20c h20d h20e h20f h20g h20h35 h21a h21b h21c h21d h21e h21f h21g h21h6 h24a h24b h24c h24d h24e h24f h24g h24h4 h27a h27b h27c h27d h27e h27f h27g h27h2 h36a h36b h36c h36d h36e h36f h36g h36h15 h38a h38b h38c h38d h38e h38f h38g h38h15 h41a h41b h41c h41d h41e h41f h41g h41h20 h47a h47b h47c h47d h47e h47f h47g h47h6 h57a h57b h57c h57d h57e h57f h57g h57h17 h69a h69b h69c h69d h69e h69f h69g h69h6 h72a h72b h72c h72d h72e h72f h72g h72h15 h76a h76b h76c h76d h76e h76f h76g h76h15 h77a h77b h77c h77d h77e h77f h77g h77h4 h92a h92b h92c h92d h92e h92f h92g h92h6 h96a h96b h96c h96d h96e h96f h96g h96h37 h1a h1b h1c h1d h1e h1f h1g h1h6 h3a h3b h3c h3d h3e h3f h3g h3h64 h6a h6b h6c h6d h6e h6f h6g h6h64 h10a h10b h10c h10d h10e h10f h10g h10h37 h11a h11b h11c h11d h11e h11f h11g h11h0 h20a h20b h20c h20d h20e h20f h20g h20h35 h21a h21b h21c h21d h21e h21f h21g h21h6 h24a h24b h24c h24d h24e h24f h24g h24h4 h27a h27b h27c h27d h27e h27f h27g h27h2 h36a h36b h36c h36d h36e h36f h36g h36h15 h38a h38b h38c h38d h38e h38f h38g h38h15 h41a h41b h41c h41d h41e h41f h41g h41h20 h47a h47b h47c h47d h47e h47f h47g h47h6 h57a h57b h57c h57d h57e h57f h57g h57h17 h69a h69b h69c h69d h69e h69f h69g h69h6 h72a h72b h72c h72d h72e h72f h72g h72h15 h76a h76b h76c h76d h76e h76f h76g h76h15 h77a h77b h77c h77d h77e h77f h77g h77h4 h92a h92b h92c h92d h92e h92f h92g h92h6 h96a h96b h96c h96d h96e h96f h96g h96h37"}, "expect": {"simhashes": ["ab8d18d121e1aed8", "ab0d18d161e1ae98"], "distance": 3, "fails": ["floor_attestation", "floor_anchor"], "agree": false, "matches": [[1, 0], [3, 1], [6, 2], [10, 3], [11, 4], [20, 5], [21, 6], [24, 7], [27, 8], [36, 9], [38, 10], [41, 11], [47, 12], [57, 13], [69, 14], [72, 15], [76, 16], [77, 17], [92, 18], [96, 19]], "matched_bytes": 787, "agreed_text": "\u2026 h1a h1b h1c h1d h1e h1f h1g h1h6 \u2026 h3a h3b h3c h3d h3e h3f h3g h3h64 \u2026 h6a h6b h6c h6d h6e h6f h6g h6h64 \u2026 h10a h10b h10c h10d h10e h10f h10g h10h37 h11a h11b h11c h11d h11e h11f h11g h11h0 \u2026 h20a h20b h20c h20d h20e h20f h20g h20h35 h21a h21b h21c h21d h21e h21f h21g h21h6 \u2026 h24a h24b h24c h24d h24e h24f h24g h24h4 \u2026 h27a h27b h27c h27d h27e h27f h27g h27h2 \u2026 h36a h36b h36c h36d h36e h36f h36g h36h15 \u2026 h38a h38b h38c h38d h38e h38f h38g h38h15 \u2026 h41a h41b h41c h41d h41e h41f h41g h41h20 \u2026 h47a h47b h47c h47d h47e h47f h47g h47h6 \u2026 h57a h57b h57c h57d h57e h57f h57g h57h17 \u2026 h69a h69b h69c h69d h69e h69f h69g h69h6 \u2026 h72a h72b h72c h72d h72e h72f h72g h72h15 \u2026 h76a h76b h76c h76d h76e h76f h76g h76h15 h77a h77b h77c h77d h77e h77f h77g h77h4 \u2026 h92a h92b h92c h92d h92e h92f h92g h92h6 \u2026 h96a h96b h96c h96d h96e h96f h96g h96h37 \u2026", "agreed_bytes": 878}}, + {"id": "completer-swaps-two-parts", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "expect": {"simhashes": ["5a0dbcee8f4a19c1", "5a0dbcee9f4a1881"], "distance": 3, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [8, 7], [9, 9], [10, 10], [11, 11], [12, 12], [13, 13], [14, 14], [15, 15], [16, 16], [17, 17]], "matched_bytes": 4408, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. \u2026 provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of \u2026 the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service.", "agreed_bytes": 4432, "title": "Tidal power", "content_hash": "412a2fb13d157428e2bfa05da9317923214093d8eb7073d8357aa6fafc6d854f"}}, + {"id": "completer-moves-the-last-part-to-the-front", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service. Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and"}, "expect": {"simhashes": ["5a0dbcee8f4a19c1", "5a0dbcee8f4a1981"], "distance": 1, "fails": [], "agree": true, "matches": [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10], [11, 11], [12, 12], [13, 13], [14, 14], [15, 15], [16, 16]], "matched_bytes": 4396, "agreed_text": "\u2026 such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and \u2026", "agreed_bytes": 4419, "title": "Tidal power", "content_hash": "7435e7bbb225dfb967c74f9c883c2ddc93be0b655a077341ed3f6d102280efde"}}, + {"id": "completer-rotates-the-page", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service. Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of"}, "expect": {"simhashes": ["5a0dbcee8f4a19c1", "5a0dbcee8f4a19c1"], "distance": 0, "fails": ["floor_attestation", "floor_anchor"], "agree": false, "matches": [[9, 0], [10, 1], [11, 2], [12, 3], [13, 4], [14, 5], [15, 6], [16, 7]], "matched_bytes": 2104, "agreed_text": "\u2026 the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and \u2026", "agreed_bytes": 2119}}, + {"id": "unicode-pair-with-a-rotated-block", "anchor": {"title": "Mar\u00e9es et \u00e9nergie", "lang": "fr", "text": "L'\u00e9nergie mar\u00e9motrice transforme le va-et-vient des mar\u00e9es en \u00e9lectricit\u00e9\u00a0: c'est la plus r\u00e9guli\u00e8re des \u00e9nergies de la mer. La Lune et le Soleil attirent les oc\u00e9ans, et dans un estuaire \u00e9troit la diff\u00e9rence entre la haute et la basse mer peut d\u00e9passer douze m\u00e8tres. Publicit\u00e9\u00a0: assurance bateau, jusqu'\u00e0 trente pour cent d'\u00e9conomie. L'usine de la Rance, en Bretagne, a \u00e9t\u00e9 mise en service en 1966. Elle compte vingt-quatre turbines et produit chaque ann\u00e9e environ cinq cent soixante gigawattheures\u00a0; ses ing\u00e9nieurs ont d\u00fb r\u00e9soudre des probl\u00e8mes de corrosion, de sable et de poissons migrateurs, puis surveiller pendant des d\u00e9cennies l'\u00e9volution de la vasi\u00e8re situ\u00e9e en amont. Les hydroliennes fonctionnent autrement\u00a0: elles sont pos\u00e9es dans le courant, comme des \u00e9oliennes sous l'eau. L'eau \u00e9tant environ huit cents fois plus dense que l'air, un courant lent suffit pour entra\u00eener des pales bien plus petites. Les premiers parcs, en \u00c9cosse et au Raz Blanchard, livrent d\u00e9j\u00e0 de l'\u00e9lectricit\u00e9 au r\u00e9seau. Reste la question du co\u00fbt\u00a0: l'\u00e9quipement marin doit r\u00e9sister \u00e0 la temp\u00eate, au sel et aux courants violents, et chaque intervention exige un navire et une fen\u00eatre m\u00e9t\u00e9o favorable. Les partisans r\u00e9pondent que la pr\u00e9visibilit\u00e9 des mar\u00e9es facilite la gestion du r\u00e9seau\u00a0; les \u00e9cologistes demandent quant \u00e0 eux que les projets soient jug\u00e9s sur leur effet r\u00e9el sur les oiseaux, les poissons et les s\u00e9diments. Mis \u00e0 jour le 14 octobre 2026 \u00e0 09 h 12."}, "attestation": {"title": "Mar\u00e9es et \u00e9nergie", "lang": "fr", "text": "L'\u00e9nergie mar\u00e9motrice transforme le va-et-vient des mar\u00e9es en \u00e9lectricit\u00e9\u00a0: c'est la plus r\u00e9guli\u00e8re des \u00e9nergies de la mer. La Lune et le Soleil attirent les oc\u00e9ans, et dans un estuaire \u00e9troit la diff\u00e9rence entre la haute et la basse mer peut d\u00e9passer douze m\u00e8tres. Publicit\u00e9\u00a0: places de port, quinze pour cent de r\u00e9duction cet \u00e9t\u00e9. L'usine de la Rance, en Bretagne, a \u00e9t\u00e9 mise en service en 1966. Elle compte vingt-quatre turbines et produit chaque ann\u00e9e environ cinq cent soixante gigawattheures\u00a0; ses ing\u00e9nieurs ont d\u00fb r\u00e9soudre des probl\u00e8mes de corrosion, de sable et de poissons migrateurs, puis surveiller pendant des d\u00e9cennies l'\u00e9volution de la vasi\u00e8re situ\u00e9e en amont. Les hydroliennes fonctionnent autrement\u00a0: elles sont pos\u00e9es dans le courant, comme des \u00e9oliennes sous l'eau. L'eau \u00e9tant environ huit cents fois plus dense que l'air, un courant lent suffit pour entra\u00eener des pales bien plus petites. Les premiers parcs, en \u00c9cosse et au Raz Blanchard, livrent d\u00e9j\u00e0 de l'\u00e9lectricit\u00e9 au r\u00e9seau. Reste la question du co\u00fbt\u00a0: l'\u00e9quipement marin doit r\u00e9sister \u00e0 la temp\u00eate, au sel et aux courants violents, et chaque intervention exige un navire et une fen\u00eatre m\u00e9t\u00e9o favorable. Les partisans r\u00e9pondent que la pr\u00e9visibilit\u00e9 des mar\u00e9es facilite la gestion du r\u00e9seau\u00a0; les \u00e9cologistes demandent quant \u00e0 eux que les projets soient jug\u00e9s sur leur effet r\u00e9el sur les oiseaux, les poissons et les s\u00e9diments. Mis \u00e0 jour le 14 octobre 2026 \u00e0 11 h 47."}, "expect": {"simhashes": ["e76a0572e6078fa9", "af6a8572e6278ea9"], "distance": 5, "fails": [], "agree": true, "matches": [[0, 0], [3, 2], [4, 3], [5, 4], [6, 5]], "matched_bytes": 1077, "agreed_text": "L'\u00e9nergie mar\u00e9motrice transforme le va-et-vient des mar\u00e9es en \u00e9lectricit\u00e9 : c'est la plus r\u00e9guli\u00e8re des \u00e9nergies de la mer. La Lune et le Soleil attirent les oc\u00e9ans, et \u2026 mise en service en 1966. Elle compte vingt-quatre turbines et produit chaque ann\u00e9e environ cinq cent soixante gigawattheures ; ses ing\u00e9nieurs ont d\u00fb r\u00e9soudre des probl\u00e8mes de corrosion, de sable et de poissons migrateurs, puis surveiller pendant des d\u00e9cennies l'\u00e9volution de la vasi\u00e8re situ\u00e9e en amont. Les hydroliennes fonctionnent autrement : elles sont pos\u00e9es dans le courant, comme des \u00e9oliennes sous l'eau. L'eau \u00e9tant environ huit cents fois plus dense que l'air, un courant lent suffit pour entra\u00eener des pales bien plus petites. Les premiers parcs, en \u00c9cosse et au Raz Blanchard, livrent d\u00e9j\u00e0 de l'\u00e9lectricit\u00e9 au r\u00e9seau. Reste la question du co\u00fbt : l'\u00e9quipement marin doit r\u00e9sister \u00e0 la temp\u00eate, au sel et aux courants violents, et chaque intervention exige un navire et une fen\u00eatre m\u00e9t\u00e9o favorable. Les partisans r\u00e9pondent que la pr\u00e9visibilit\u00e9 des mar\u00e9es facilite \u2026", "agreed_bytes": 1089, "title": "Mar\u00e9es et \u00e9nergie", "content_hash": "a37752b27926eb0cec413d7732bf20af4305ba515f8607c1d987f14a10ba597b"}}, + {"id": "simhash-distance-6-is-within-the-bound", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["522db4d50f4b09c5", "572df4c50f5b09cd"], "distance": 6, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 9], [9, 10]], "matched_bytes": 2601, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together \u2026 the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2614, "title": "Tidal power", "content_hash": "0292e87f992a2bb89b39af29a42082558c36ce76e40c3e8d967b333709b9ea15"}}, + {"id": "different-title", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power: why investors should sell", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["572df4c50f5b09cd", "572df4c50f5b09cd"], "distance": 0, "fails": ["title"], "agree": false, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10]], "matched_bytes": 2804, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2814}}, + {"id": "different-lang", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en-GB", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["572df4c50f5b09cd", "572df4c50f5b09cd"], "distance": 0, "fails": ["lang"], "agree": false, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9], [10, 10]], "matched_bytes": 2804, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2814}}, + {"id": "agreed-text-below-60-percent-of-the-attestation", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Tidal power turns the regular rise andx fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuaryx funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice ax day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low damx with gates and turbines set into it. At high tide the gates open and the bay fills; at low tidex the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-fourx turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the saltx and the fish that pass through the estuary. A barrage changes thex bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drierx than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, andx several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbinesx work differently. Instead of a dam, they stand in the open current likex underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal ofx energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coastx of Scotland, has delivered power to the grid since 2017. Cost remains thex main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive inx the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallenx as designs have been standardised and the first arrays have gathered operating data. Supporters point tox predictability. Wind and sunshine vary with the weather, but the tide table is knownx long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread alongx a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a seax wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, whilex critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion inx Britain, China and South Korea."}, "expect": {"simhashes": ["572df4c50f5b09cd", "132da4c50d5b09c9"], "distance": 6, "fails": ["floor_attestation"], "agree": false, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9]], "matched_bytes": 2677, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are \u2026", "agreed_bytes": 2690}}, + {"id": "agreed-text-below-60-percent-of-the-anchor", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. Tidal power turns the regular rise andx fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuaryx funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice ax day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low damx with gates and turbines set into it. At high tide the gates open and the bay fills; at low tidex the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-fourx turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the saltx and the fish that pass through the estuary. A barrage changes thex bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drierx than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, andx several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbinesx work differently. Instead of a dam, they stand in the open current likex underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal ofx energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coastx of Scotland, has delivered power to the grid since 2017. Cost remains thex main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive inx the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallenx as designs have been standardised and the first arrays have gathered operating data. Supporters point tox predictability. Wind and sunshine vary with the weather, but the tide table is knownx long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread alongx a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a seax wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, whilex critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion inx Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["132da4c50d5b09c9", "572df4c50f5b09cd"], "distance": 6, "fails": ["floor_anchor"], "agree": false, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8], [9, 9]], "matched_bytes": 2677, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are \u2026", "agreed_bytes": 2690}}, + {"id": "agreed-text-at-exactly-60-percent-of-the-attestation", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. A power station built across such a bay can capture that difference twice ax day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low damx with gates and turbines set into it. At high tide the gates open and the bay fills; at low tidex the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-fourx turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the saltx and the fish that pass through the estuary. A barrage changes thex bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drierx than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, andx several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbinesx work differently. Instead of a dam, they stand in the open current likex underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal ofx energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coastx of Scotland, has delivered power to the grid since 2017. Cost remains thex main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive inx the first place, and every maintenance visit needs axxxxxx"}, "expect": {"simhashes": ["532df4d50f5b09c9", "5725b4d50f4b29c1"], "distance": 6, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8]], "matched_bytes": 2697, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are \u2026", "agreed_bytes": 2709, "title": "Tidal power", "content_hash": "cc348ee258617f9ec3069c35d1f29d41b6bd734fa1b2cef4ef34bcc074b83eca"}}, + {"id": "agreed-text-one-byte-below-60-percent-of-the-attestation", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. A power station built across such a bay can capture that difference twice ax day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low damx with gates and turbines set into it. At high tide the gates open and the bay fills; at low tidex the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-fourx turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the saltx and the fish that pass through the estuary. A barrage changes thex bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drierx than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, andx several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbinesx work differently. Instead of a dam, they stand in the open current likex underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal ofx energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coastx of Scotland, has delivered power to the grid since 2017. Cost remains thex main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive inx the first place, and every maintenance visit needs axxxxxxx"}, "expect": {"simhashes": ["532df4d50f5b09c9", "1725b4d50f5b29c1"], "distance": 6, "fails": ["floor_attestation"], "agree": false, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8]], "matched_bytes": 2697, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are \u2026", "agreed_bytes": 2709}}, + {"id": "agreed-text-at-exactly-60-percent-of-the-anchor", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. A power station built across such a bay can capture that difference twice ax day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low damx with gates and turbines set into it. At high tide the gates open and the bay fills; at low tidex the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-fourx turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the saltx and the fish that pass through the estuary. A barrage changes thex bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drierx than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, andx several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbinesx work differently. Instead of a dam, they stand in the open current likex underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal ofx energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coastx of Scotland, has delivered power to the grid since 2017. Cost remains thex main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive inx the first place, and every maintenance visit needs axxxxxx"}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["5725b4d50f4b29c1", "532df4d50f5b09c9"], "distance": 6, "fails": [], "agree": true, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8]], "matched_bytes": 2697, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are \u2026", "agreed_bytes": 2709, "title": "Tidal power", "content_hash": "cc348ee258617f9ec3069c35d1f29d41b6bd734fa1b2cef4ef34bcc074b83eca"}}, + {"id": "agreed-text-one-byte-below-60-percent-of-the-anchor", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea. A power station built across such a bay can capture that difference twice ax day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low damx with gates and turbines set into it. At high tide the gates open and the bay fills; at low tidex the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-fourx turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the saltx and the fish that pass through the estuary. A barrage changes thex bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drierx than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, andx several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbinesx work differently. Instead of a dam, they stand in the open current likex underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal ofx energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coastx of Scotland, has delivered power to the grid since 2017. Cost remains thex main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive inx the first place, and every maintenance visit needs axxxxxxx"}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["1725b4d50f5b29c1", "532df4d50f5b09c9"], "distance": 6, "fails": ["floor_anchor"], "agree": false, "matches": [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4], [5, 5], [6, 6], [7, 7], [8, 8]], "matched_bytes": 2697, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The earliest design is the barrage, a low dam with gates and turbines built into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the whole bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work quite differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the biggest obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are \u2026", "agreed_bytes": 2709}}, + {"id": "agreed-text-of-exactly-80-bytes", "anchor": {"title": "Tidal power", "lang": "en", "text": "The Rance station in Brittany opened in 1966 and still supplies large energy See also: Tidal Energy"}, "attestation": {"title": "Tidal power", "lang": "en", "text": "The Rance station in Brittany opened in 1966 and still supplies large energy see also tidal energy."}, "expect": {"simhashes": ["2e656ac0480afa6b", "2e656ac0480afa6b"], "distance": 0, "fails": [], "agree": true, "matches": [[0, 0]], "matched_bytes": 76, "agreed_text": "The Rance station in Brittany opened in 1966 and still supplies large energy \u2026", "agreed_bytes": 80, "title": "Tidal power", "content_hash": "c8e0290e1d225fe6731b8f98d706cb2a4b30a884be61df21ae885d05bcb0ca8c"}}, + {"id": "agreed-text-of-79-bytes", "anchor": {"title": "Tidal power", "lang": "en", "text": "The Rance station in Brittany opened in 1966 and still supplies green homes See also: Tidal Energy"}, "attestation": {"title": "Tidal power", "lang": "en", "text": "The Rance station in Brittany opened in 1966 and still supplies green homes see also tidal energy."}, "expect": {"simhashes": ["25246a5c4d9afa6f", "25246a5c4d9afa6f"], "distance": 0, "fails": ["agreed_minimum"], "agree": false, "matches": [[0, 0]], "matched_bytes": 75, "agreed_text": "The Rance station in Brittany opened in 1966 and still supplies green homes \u2026", "agreed_bytes": 79}}, + {"id": "agreed-text-far-under-80-bytes", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tides rise and fall twice a day along small waters See also: Tidal Energy Systems"}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tides rise and fall twice a day along small waters see also tidal energy systems."}, "expect": {"simhashes": ["84c961e52c2ffd53", "84c961e52c2ffd53"], "distance": 0, "fails": ["agreed_minimum"], "agree": false, "matches": [[0, 0]], "matched_bytes": 50, "agreed_text": "Tides rise and fall twice a day along small waters \u2026", "agreed_bytes": 54}}, + {"id": "simhash-distance-7-is-beyond-the-bound", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["5f2df4d71e5b29c5", "572df4c50f5b09cd"], "distance": 7, "fails": ["simhash"], "agree": false, "matches": [[0, 0], [1, 1], [2, 2], [3, 4], [4, 5], [5, 6], [6, 7], [7, 8], [8, 9], [9, 10]], "matched_bytes": 2497, "agreed_text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released \u2026 behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea.", "agreed_bytes": 2510}}, + {"id": "a-word-changed-in-every-part", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where as narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead.s The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at lows tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours everys year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow ofs sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and thes moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017.s Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather,s but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose as stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuarys than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plan remain under discussion in Britain, China and South Korea."}, "expect": {"simhashes": ["572df4c50f5b09cd", "572db4c50f5e29c1"], "distance": 6, "fails": ["floor_attestation", "floor_anchor", "agreed_minimum"], "agree": false, "matches": [], "matched_bytes": 0, "agreed_text": "", "agreed_bytes": 0}}, + {"id": "unrelated-pages", "anchor": {"title": "Tidal power", "lang": "en", "text": "Tidal power turns the regular rise and fall of the sea into electricity. The moon and the sun pull on the oceans, and where a narrow bay or estuary funnels the water, the difference between high and low tide can reach many metres. A power station built across such a bay can capture that difference twice a day, in every season, on a timetable that engineers can publish years ahead. The oldest design is the barrage, a low dam with gates and turbines set into it. At high tide the gates open and the bay fills; at low tide the water is released through the turbines and the generators spin. The Rance station in Brittany, which opened in 1966, uses twenty-four turbines and still supplies several hundred gigawatt-hours every year. Its builders also had to manage the silt, the salt and the fish that pass through the estuary. A barrage changes the bay behind it. Mudflats that were covered and uncovered twice a day may stay wetter or drier than before, wading birds lose feeding grounds, and the flow of sediment shifts. Environmental reviews of later proposals therefore weigh the energy gained against the habitat altered, and several large schemes have been shelved because the estimated damage outweighed the benefit. Tidal stream turbines work differently. Instead of a dam, they stand in the open current like underwater wind turbines, and the moving water turns the blades. Because water is about eight hundred times denser than air, a slow current carries a great deal of energy, and the machines can be much smaller than a wind turbine of the same output. The MeyGen array in the Pentland Firth, off the north coast of Scotland, has delivered power to the grid since 2017. Cost remains the main obstacle. Marine equipment must survive corrosion, storms and the strong currents that make a site attractive in the first place, and every maintenance visit needs a vessel and a calm window. Early projects have been expensive per unit of electricity, although the price has fallen as designs have been standardised and the first arrays have gathered operating data. Supporters point to predictability. Wind and sunshine vary with the weather, but the tide table is known long in advance, so operators and grid managers can plan around it. Tides in different places peak at different hours, so a handful of sites spread along a coast could together provide a steadier supply than any one of them alone. Researchers are also studying tidal lagoons, which enclose a stretch of shoreline with a sea wall and generate power as the water flows in and out of the pool. Supporters say a lagoon disturbs less of the open estuary than a full barrage, while critics note that the walls are long, costly and still change the local habitat. Several pilot plans remain under discussion in Britain, China and South Korea."}, "attestation": {"title": "Geothermal energy", "lang": "en", "text": "Geothermal energy taps the heat stored beneath the surface of the Earth. In some regions hot rock lies close enough to the surface that wells a few kilometres deep reach water at two hundred degrees or more, and the steam or hot water that rises can drive a turbine or warm the buildings of an entire town. Iceland is the best known example. Volcanic activity under the island heats the groundwater, and nearly every home in Reykjavik is warmed by water piped from nearby fields. The same fields supply a large share of the country's electricity, which is why heavy industry such as aluminium smelting has settled there. A conventional plant needs three things at once: heat, water and permeable rock. Where one is missing, engineers sometimes build an enhanced system, drilling two wells into hot dry rock and fracturing the space between them so that injected water can circulate, pick up heat and return to the surface. The method opens many more sites, but it has also caused small earthquakes near some projects, and operators now monitor the ground closely. Compared with wind and solar power, geothermal plants run day and night in any weather, so they can supply the steady base load that a grid needs. Their fuel costs nothing, although drilling is expensive and a poorly placed well can dry up. Operators reinject the used water to keep the reservoir full, which also limits the release of dissolved gases. Smaller systems exist too. A heat pump buried in the garden of a house moves warmth from the ground into the building in winter and reverses the flow in summer, using far less electricity than a furnace or a conventional air conditioner. Millions of such installations now operate in Sweden, the United States and China. The main limits are geography and cost. The richest fields lie along plate boundaries, far from many large cities, and an exploratory well can cost as much as a small factory before anyone knows whether it will produce. Public loan guarantees and shared drilling data have helped several countries bring new fields into service."}, "expect": {"simhashes": ["572df4c50f5b09cd", "ca0d8a6a956c5aa2"], "distance": 37, "fails": ["simhash", "title", "floor_attestation", "floor_anchor", "agreed_minimum"], "agree": false, "matches": [], "matched_bytes": 0, "agreed_text": "", "agreed_bytes": 0}} + ] + }, "ledger_clamp": [ {"balance": 300, "abs_amount": 500, "applied_delta": -300}, {"balance": 0, "abs_amount": 500, "applied_delta": 0}, diff --git a/internal/community/vectors_test.go b/internal/community/vectors_test.go index b55ed6a..92dbfa7 100644 --- a/internal/community/vectors_test.go +++ b/internal/community/vectors_test.go @@ -3,11 +3,13 @@ package community import ( "crypto/sha256" "encoding/hex" + "encoding/json" "os" + "reflect" "testing" ) -const contributionVectorsSHA256 = "730491f4a3d7c065361d7b212a8b0336f85672136b09fd75d9b4935481c9f824" +const contributionVectorsSHA256 = "91a056ce70345aa10558983dad60f7103490426ff774021a33d9209569e857b9" func TestContributionVectorsAreTheSharedCopy(t *testing.T) { raw, err := os.ReadFile("testdata/contribution-vectors-v1.json") @@ -19,3 +21,27 @@ func TestContributionVectorsAreTheSharedCopy(t *testing.T) { t.Fatal("testdata/contribution-vectors-v1.json differs from the shared vectors; take the new copy from the contract set and update the pinned hash") } } + +func TestAwardVectorsAreTheSharedGroups(t *testing.T) { + load := func(name string) map[string]any { + raw, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out + } + shared, award := load("testdata/contribution-vectors-v1.json"), load("testdata/award-vectors.json") + if len(award) != 2 { + t.Fatalf("testdata/award-vectors.json holds %d groups, want award_ids and ledger_clamp only", len(award)) + } + for _, group := range []string{"award_ids", "ledger_clamp"} { + want, ok := shared[group] + if !ok || !reflect.DeepEqual(award[group], want) { + t.Errorf("testdata/award-vectors.json: %s differs from the shared vectors; copy the group from testdata/contribution-vectors-v1.json", group) + } + } +} From 741d9a1c665d33b2ca6985bd91c88d23441187ab Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 12:11:45 +0300 Subject: [PATCH 110/121] fix(cosift): a clawback resolves through the uid link and never expires to skipped A suspended or unverified account used to leave its clawback unresolved until it was skipped after a week, and the credits came back once the account was restored. A debit needs no verified email, so it now uses the linked community user directly; with no link it keeps waiting for the account and stays pending. Positive awards resolve and expire as before. --- internal/community/awards.go | 58 ++++++-- internal/community/awards_clawback_test.go | 165 +++++++++++++++++++++ 2 files changed, 209 insertions(+), 14 deletions(-) create mode 100644 internal/community/awards_clawback_test.go diff --git a/internal/community/awards.go b/internal/community/awards.go index e6dd596..7ee87ed 100644 --- a/internal/community/awards.go +++ b/internal/community/awards.go @@ -225,7 +225,8 @@ func (s *Server) settleAward(ctx context.Context, a sharedaccount.Award) { default: failures := p.fail(a.ID) log.Printf("community: award_unresolved award=%s class=%s", quoted(a.ID), out.detail) - if reason := terminalReason(out.detail); reason != "" && failures >= 2 && p.now().Sub(a.CreatedAt) >= awardTerminalAge { + // A clawback stays pending: skipping it would leave the credits spendable. + if reason := terminalReason(out.detail); reason != "" && a.Reason != "clawback" && failures >= 2 && p.now().Sub(a.CreatedAt) >= awardTerminalAge { s.expireAward(ctx, a, reason) } } @@ -312,34 +313,63 @@ func (s *Server) applyAward(ctx context.Context, a sharedaccount.Award) awardOut return awardOutcome{"invalid", "article_mismatch"} } } + userID, out, ok := s.awardAccount(ctx, a) + if !ok { + return out + } + if err := p.step("insert"); err != nil { + return retry("ledger") + } + delta, err := s.writeAward(ctx, a, userID) + if err != nil { + return retry("ledger") + } + if err := p.step("mark"); err != nil { + return retry("mark") + } + return s.finishAward(ctx, a, sharedaccount.AwardResult{Status: "applied", AppliedDelta: delta, LedgerID: "award:" + a.ID}) +} + +// awardAccount finds the community user an award is for; a clawback uses the uid link alone, since a debit needs no verified email. +func (s *Server) awardAccount(ctx context.Context, a sharedaccount.Award) (string, awardOutcome, bool) { + p := s.awards + retry := func(class string) (string, awardOutcome, bool) { return "", awardOutcome{"retry", class}, false } + clawback := a.Reason == "clawback" + if clawback { + id, found, err := s.linkedUser(ctx, a.UID) + if err != nil { + return retry("link") + } + if found { + return id, awardOutcome{}, true + } + } if err := p.step("account"); err != nil { return retry("account") } identity, err := p.src.Account(ctx, a.UID) switch { - case errors.Is(err, sharedaccount.ErrUnauthorized): - return s.skipAward(ctx, a, "unknown_account") + case errors.Is(err, sharedaccount.ErrUnauthorized) && !clawback: + return "", s.skipAward(ctx, a, "unknown_account"), false case err != nil || identity.UID != a.UID: return retry("account") } if err := p.step("link"); err != nil { return retry("link") } - userID, err := s.awardUser(ctx, identity) + id, err := s.awardUser(ctx, identity) if err != nil { return retry("link") } - if err := p.step("insert"); err != nil { - return retry("ledger") - } - delta, err := s.writeAward(ctx, a, userID) - if err != nil { - return retry("ledger") - } - if err := p.step("mark"); err != nil { - return retry("mark") + return id, awardOutcome{}, true +} + +func (s *Server) linkedUser(ctx context.Context, uid string) (id string, found bool, err error) { + err = s.db.QueryRowContext(ctx, `SELECT user_id FROM shared_identities WHERE uid=?`, uid).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil } - return s.finishAward(ctx, a, sharedaccount.AwardResult{Status: "applied", AppliedDelta: delta, LedgerID: "award:" + a.ID}) + return id, err == nil, err } func (s *Server) finishAward(ctx context.Context, a sharedaccount.Award, result sharedaccount.AwardResult) awardOutcome { diff --git a/internal/community/awards_clawback_test.go b/internal/community/awards_clawback_test.go new file mode 100644 index 0000000..fb80276 --- /dev/null +++ b/internal/community/awards_clawback_test.go @@ -0,0 +1,165 @@ +package community + +import ( + "testing" + "time" + + "github.com/pilot-protocol/cosift/internal/sharedaccount" +) + +func TestAClawbackDebitsASuspendedOrUnverifiedAccountAndLiftingItRestoresNothing(t *testing.T) { + for name, hold := range map[string]func(r *awardRig){ + "suspended": func(r *awardRig) { r.src.accountErr[uidN(1)] = sharedaccount.ErrBanned }, + "unverified": func(r *awardRig) { delete(r.src.accounts, uidN(1)) }, + } { + t.Run(name, func(t *testing.T) { + r := newAwardRig(t) + r.articleAward(1, 1, 1000) + r.tick(t) + user := r.userID(t, 1) + claw := r.clawback(1, 1, 1000) + hold(r) + r.tick(t) + res := r.src.result(claw.ID) + if delta, found := r.ledger(t, claw.ID); !found || delta != -1000 || r.balance(t, user) != 0 { + t.Fatalf("ledger %d (%v), balance %d", delta, found, r.balance(t, user)) + } + if res == nil || res.Status != "applied" || res.AppliedDelta != -1000 { + t.Fatalf("result %+v", res) + } + delete(r.src.accountErr, uidN(1)) + r.person(1) + for i := 0; i < 3; i++ { + r.now = r.now.Add(2 * time.Hour) + r.tick(t) + } + if r.balance(t, user) != 0 || r.ledgerRows(t) != 2 { + t.Fatalf("balance %d, ledger rows %d after the hold was lifted", r.balance(t, user), r.ledgerRows(t)) + } + }) + } +} + +func TestAClawbackWithoutALinkStaysPendingPastAWeekAndIsRetried(t *testing.T) { + for name, hold := range map[string]func(r *awardRig){ + "suspended": func(r *awardRig) { r.src.accountErr[uidN(1)] = sharedaccount.ErrBanned }, + "unverified": func(r *awardRig) { delete(r.src.accounts, uidN(1)) }, + } { + t.Run(name, func(t *testing.T) { + r := newAwardRig(t) + claw := r.clawback(1, 1, 500) + r.src.awards[claw.ID].a.CreatedAt = r.now.Add(-8 * 24 * time.Hour) + hold(r) + reads := func() int { return r.src.calls["Account"] } + var midway int + for i := 0; i < 24; i++ { + r.now = r.now.Add(2 * time.Hour) + r.tick(t) + if i == 11 { + midway = reads() + } + } + if res := r.src.result(claw.ID); res != nil || r.ledgerRows(t) != 0 { + t.Fatalf("result %+v, ledger rows %d", res, r.ledgerRows(t)) + } + if reads() <= midway { + t.Fatalf("no retry in the second day: %d reads, %d midway", reads(), midway) + } + r.person(1) + delete(r.src.accountErr, uidN(1)) + r.now = r.now.Add(2 * time.Hour) + r.tick(t) + if res := r.src.result(claw.ID); res == nil || res.Status != "applied" || res.AppliedDelta != 0 { + t.Fatalf("result %+v once the account resolved", res) + } + }) + } +} + +func TestSuspendedAccountsKeepWaitingForPositiveAwardsEvenWithALink(t *testing.T) { + r := newAwardRig(t) + r.articleAward(1, 1, 1000) + r.tick(t) + user := r.userID(t, 1) + r.src.accountErr[uidN(1)] = sharedaccount.ErrBanned + later := []sharedaccount.Award{r.articleAward(1, 2, 700), r.royalty(1, 5, "2026-10-19")} + r.tick(t) + for _, a := range later { + if r.src.result(a.ID) != nil { + t.Fatalf("%s was settled while the account was suspended", a.ID) + } + r.src.awards[a.ID].a.CreatedAt = r.now.Add(-8 * 24 * time.Hour) + } + for i := 0; i < 4; i++ { + r.now = r.now.Add(2 * time.Hour) + r.tick(t) + } + for _, a := range later { + if res := r.src.result(a.ID); res == nil || res.Status != "skipped" || res.SkipReason != "account_unresolvable" { + t.Fatalf("%s: %+v", a.ID, res) + } + } + if r.balance(t, user) != 1000 || r.ledgerRows(t) != 1 { + t.Fatalf("balance %d, ledger rows %d", r.balance(t, user), r.ledgerRows(t)) + } +} + +func TestAnOldClawbackStaysPendingWhenTheLinkStepFails(t *testing.T) { + for name, setup := range map[string]struct { + fail func(r *awardRig) + restore func(r *awardRig) + }{ + "the link is written": { + fail: func(r *awardRig) { r.s.awards.hook = failStep("link") }, + restore: func(r *awardRig) { r.s.awards.hook = nil }, + }, + "the link is read": { + fail: func(r *awardRig) { + if _, err := r.s.db.Exec(`ALTER TABLE shared_identities RENAME TO shared_identities_held`); err != nil { + t.Fatal(err) + } + }, + restore: func(r *awardRig) { + if _, err := r.s.db.Exec(`ALTER TABLE shared_identities_held RENAME TO shared_identities`); err != nil { + t.Fatal(err) + } + }, + }, + } { + t.Run(name, func(t *testing.T) { + r := newAwardRig(t) + if name == "the link is read" { + r.articleAward(1, 1, 1000) + r.tick(t) + } + claw := r.clawback(1, 1, 400) + r.src.awards[claw.ID].a.CreatedAt = r.now.Add(-8 * 24 * time.Hour) + setup.fail(r) + for i := 0; i < 6; i++ { + r.now = r.now.Add(2 * time.Hour) + r.tick(t) + } + if res := r.src.result(claw.ID); res != nil { + t.Fatalf("result %+v", res) + } + setup.restore(r) + r.now = r.now.Add(2 * time.Hour) + r.tick(t) + if res := r.src.result(claw.ID); res == nil || res.Status != "applied" { + t.Fatalf("result %+v once the link step worked", res) + } + if _, found := r.ledger(t, claw.ID); !found { + t.Fatal("no ledger row") + } + }) + } +} + +func failStep(name string) func(string) error { + return func(step string) error { + if step == name { + return errEngineDown + } + return nil + } +} From 39557af4ecfd940ae5d633cdd01c8bad126ac7e7 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 12:43:49 +0300 Subject: [PATCH 111/121] fix(cosift): one account can use only its share of the day's content checks The engine's daily ceiling on content checks is shared by every submission, so a single account could spend all of it and hold everyone else's submissions and cited-source pushes until 00:00 UTC. Each community user now has a per-UTC-day count of content checks (COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT, 1 to 10000, default 100), kept in the community database so a restart does not reset it. A user who has reached the share has the rest of their pending submissions requeued to the next 00:00 UTC without spending an attempt, while other users' submissions keep dispatching. --- cmd/cosift/community.go | 22 +- .../community_moderation_share_config_test.go | 81 +++++++ deploy/community.env.example | 2 + internal/community/fail_class_test.go | 2 +- internal/community/limits.go | 6 + internal/community/moderation.go | 29 ++- internal/community/moderation_binding_test.go | 2 +- internal/community/moderation_share_test.go | 229 ++++++++++++++++++ internal/community/server.go | 12 +- internal/community/store.go | 3 +- 10 files changed, 380 insertions(+), 8 deletions(-) create mode 100644 cmd/cosift/community_moderation_share_config_test.go create mode 100644 internal/community/moderation_share_test.go diff --git a/cmd/cosift/community.go b/cmd/cosift/community.go index c262255..aee4ea1 100644 --- a/cmd/cosift/community.go +++ b/cmd/cosift/community.go @@ -23,6 +23,9 @@ import ( "github.com/pilot-protocol/cosift/internal/sharedaccount" ) +// openCommunity opens the community app; tests replace it. +var openCommunity = community.Open + func runCommunity(ctx context.Context, args []string) error { fs := flag.NewFlagSet("community", flag.ContinueOnError) addr := fs.String("addr", "127.0.0.1:7780", "listen address") @@ -56,6 +59,10 @@ func runCommunity(ctx context.Context, args []string) error { if err != nil { return err } + moderationShare, err := communityModerationShareFromEnv() + if err != nil { + return err + } var trusted []string if *proxies != "" { trusted = strings.Split(*proxies, ",") @@ -73,7 +80,7 @@ func runCommunity(ctx context.Context, args []string) error { defer client.Close() provider = client } - s, err := community.Open(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, SharedPasswordEnabled: os.Getenv("COSIFT_SHARED_PASSWORD_ENABLED") == "1", DataDir: *dir, Backend: *backend, PublicURL: *publicURL, BrokerKey: os.Getenv("COSIFT_COMMUNITY_BROKER_KEY"), EngineURL: os.Getenv("COSIFT_COMMUNITY_ENGINE_URL"), Awards: awards, Work: work, FetchDailyCap: fetchCap, Environment: environment, TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1", ContributionReward: rewardOn, ContributionRewardDailyCap: rewardCap, StripePortalConfigurationID: os.Getenv("COSIFT_STRIPE_PORTAL_CONFIGURATION_ID"), GSCVerification: os.Getenv("COSIFT_GSC_VERIFICATION"), Wiki: wikiConfig()}) + s, err := openCommunity(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, SharedPasswordEnabled: os.Getenv("COSIFT_SHARED_PASSWORD_ENABLED") == "1", DataDir: *dir, Backend: *backend, PublicURL: *publicURL, BrokerKey: os.Getenv("COSIFT_COMMUNITY_BROKER_KEY"), EngineURL: os.Getenv("COSIFT_COMMUNITY_ENGINE_URL"), Awards: awards, Work: work, FetchDailyCap: fetchCap, ModerationsPerAccount: moderationShare, Environment: environment, TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1", ContributionReward: rewardOn, ContributionRewardDailyCap: rewardCap, StripePortalConfigurationID: os.Getenv("COSIFT_STRIPE_PORTAL_CONFIGURATION_ID"), GSCVerification: os.Getenv("COSIFT_GSC_VERIFICATION"), Wiki: wikiConfig()}) if err != nil { return err } @@ -132,6 +139,19 @@ func communityWorkFromEnv() (enabled bool, fetchCap int, environment string, err return enabled, fetchCap, strings.TrimSpace(os.Getenv("COSIFT_ENV")), nil } +// communityModerationShareFromEnv reads the content checks one account may use per UTC day (0 = default). +func communityModerationShareFromEnv() (int, error) { + v := strings.TrimSpace(os.Getenv("COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT")) + if v == "" { + return 0, nil + } + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > 10000 { + return 0, fmt.Errorf("COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT must be an integer from 1 to 10000") + } + return n, nil +} + // communityRewardFromEnv reads the contribution-reward switch (off by default) and the per-account daily cap (0 = default). func communityRewardFromEnv() (enabled bool, dailyCap int, err error) { switch strings.ToLower(strings.TrimSpace(os.Getenv("COSIFT_COMMUNITY_CONTRIBUTION_REWARD"))) { diff --git a/cmd/cosift/community_moderation_share_config_test.go b/cmd/cosift/community_moderation_share_config_test.go new file mode 100644 index 0000000..3e529c6 --- /dev/null +++ b/cmd/cosift/community_moderation_share_config_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/pilot-protocol/cosift/internal/community" +) + +func TestCommunityModerationShareEnv(t *testing.T) { + for _, tc := range []struct { + name, value string + want int + bad bool + }{ + {"unset", "", 0, false}, + {"blank", " ", 0, false}, + {"the floor", "1", 1, false}, + {"a value", " 250 ", 250, false}, + {"the ceiling", "10000", 10000, false}, + {"zero", "0", 0, true}, + {"negative", "-4", 0, true}, + {"above the ceiling", "10001", 0, true}, + {"text", "many", 0, true}, + {"a fraction", "2.5", 0, true}, + {"overflow", "99999999999999999999", 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT", tc.value) + got, err := communityModerationShareFromEnv() + if tc.bad { + if err == nil || !strings.Contains(err.Error(), "COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT") { + t.Fatalf("got %d, %v, want an error naming the variable", got, err) + } + return + } + if err != nil || got != tc.want { + t.Fatalf("got %d, %v, want %d", got, err, tc.want) + } + }) + } +} + +func TestCommunityRefusesAnInvalidModerationShare(t *testing.T) { + for _, v := range []string{"0", "10001", "lots"} { + t.Setenv("COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT", v) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := runCommunity(ctx, []string{"-addr", "127.0.0.1:0", "-data-dir", t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT") { + t.Fatalf("%q: got %v", v, err) + } + } +} + +func TestCommunityPassesTheModerationShareToTheApp(t *testing.T) { + for _, tc := range []struct { + value string + want int + }{{"", 0}, {"20", 20}, {"10000", 10000}} { + t.Run("value "+tc.value, func(t *testing.T) { + t.Setenv("COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT", tc.value) + errStop := errors.New("stop before serving") + var opened community.Config + previous := openCommunity + openCommunity = func(cfg community.Config) (*community.Server, error) { + opened = cfg + return nil, errStop + } + t.Cleanup(func() { openCommunity = previous }) + if err := runCommunity(context.Background(), []string{"-addr", "127.0.0.1:0", "-data-dir", t.TempDir()}); !errors.Is(err, errStop) { + t.Fatalf("got %v", err) + } + if opened.ModerationsPerAccount != tc.want { + t.Fatalf("the app got a share of %d, want %d", opened.ModerationsPerAccount, tc.want) + } + }) + } +} diff --git a/deploy/community.env.example b/deploy/community.env.example index 89d96cf..7448052 100644 --- a/deploy/community.env.example +++ b/deploy/community.env.example @@ -15,6 +15,8 @@ COSIFT_COMMUNITY_CONTRIBUTION_REWARD_DAILY_CAP= COSIFT_COMMUNITY_WORK=0 # Pages credited per account and UTC day (1 to 10000). COSIFT_COMMUNITY_FETCH_DAILY_CAP=500 +# Content checks one account may use per UTC day, out of the engine's daily ceiling; 1-10000, default 100. +COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT= # prod or staging: the environment label of the alerts the app raises. Required with COSIFT_COMMUNITY_WORK=1. COSIFT_ENV= STRIPE_SECRET_KEY= diff --git a/internal/community/fail_class_test.go b/internal/community/fail_class_test.go index 4e997ab..37f5b82 100644 --- a/internal/community/fail_class_test.go +++ b/internal/community/fail_class_test.go @@ -76,7 +76,7 @@ func TestPrevalidateClassesOnlyBlockingFailuresAsFetch(t *testing.T) { } { s := testServer(t, nil) pageAnswering(s, tc.status, tc.header, tc.body, tc.err) - _, _, _, class := s.prevalidate(context.Background(), "", "https://example.com/page") + _, _, _, class := s.prevalidate(context.Background(), job{url: "https://example.com/page"}) if class != tc.want { t.Errorf("%s: class %q, want %q", tc.name, class, tc.want) } diff --git a/internal/community/limits.go b/internal/community/limits.go index aa53e55..5bf896e 100644 --- a/internal/community/limits.go +++ b/internal/community/limits.go @@ -35,6 +35,12 @@ func (c *Config) defaultLimits() error { if c.FetchDailyCap < 1 || c.FetchDailyCap > 10000 { return fmt.Errorf("fetch daily cap must be between 1 and 10000") } + if c.ModerationsPerAccount == 0 { + c.ModerationsPerAccount = defaultModerationsPerAccount + } + if c.ModerationsPerAccount < 1 || c.ModerationsPerAccount > 10000 { + return fmt.Errorf("moderations per account must be between 1 and 10000") + } if c.GuestInterval < time.Second || c.GuestInterval > 24*time.Hour || c.GuestInterval%time.Second != 0 { return fmt.Errorf("guest interval must be whole seconds between 1s and 24h") } diff --git a/internal/community/moderation.go b/internal/community/moderation.go index 6d2859d..2d252da 100644 --- a/internal/community/moderation.go +++ b/internal/community/moderation.go @@ -3,6 +3,7 @@ package community import ( "bytes" "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -76,6 +77,10 @@ const ( // statusModerationCeiling is the dispatch loop's own outcome, never a stored status: the daily ceiling on content checks is reached. statusModerationCeiling = "moderation_ceiling" ceilingReason = "Waiting for the daily content-check allowance to renew." + // statusModerationShare is likewise never stored: the submitting account has used its own share of the day's content checks. + statusModerationShare = "moderation_share" + shareReason = "Waiting for your daily content-check allowance to renew." + defaultModerationsPerAccount = 100 ) // moderationCeiling reports the engine's ceiling answer: the same 503 a failing model gives, plus "ceiling": true. @@ -88,8 +93,18 @@ func moderationCeiling(body []byte) bool { } // holdModeration stops every content check until 00:00 UTC, because the ceiling is shared by all submissions. -func (s *Server) holdModeration() { - s.moderateHold.Store(utcDayStart(s.now()).Add(24 * time.Hour).Unix()) +func (s *Server) holdModeration() { s.moderateHold.Store(s.nextUTCDay().Unix()) } + +func (s *Server) nextUTCDay() time.Time { return utcDayStart(s.now()).Add(24 * time.Hour) } + +// reserveModeration counts one content check for the account today; false once the account has used its share. +func (s *Server) reserveModeration(ctx context.Context, userID string) (bool, error) { + var used int + err := s.db.QueryRowContext(ctx, `INSERT INTO moderation_usage(user_id,day,count) VALUES(?,?,1) ON CONFLICT(user_id,day) DO UPDATE SET count=moderation_usage.count+1 WHERE moderation_usage.count''`, nextMidnight.Unix()); held != 2 { + t.Fatalf("%d of alice's submissions are held to midnight without an attempt, want 2", held) + } + if r.s.now().Before(r.s.moderationHold()) { + t.Fatal("an account's share held every account") + } + r.s.drainAlerts(t.Context()) + if len(r.f.alerts) != 0 { + t.Fatalf("an account's share raised the ceiling alert: %+v", r.f.alerts) + } +} + +func TestTheShareSpendsNoAttemptAndRenewsAtMidnightUTC(t *testing.T) { + r := newShareRig(t, 1) + r.add("alice", "https://example.com/one") + r.add("alice", "https://example.com/two") + r.dispatch(t) + held := "https://example.com/two" + if status, attempts, next, reason := r.row(held); status != "pending" || attempts != 0 || next != nextMidnight.Unix() || reason == "" || strings.Contains(reason, "ceiling") { + t.Fatalf("%s attempts=%d next=%d reason=%q", status, attempts, next, reason) + } + r.clk.advance(3 * time.Hour) + r.dispatch(t) + r.clk.advance(11*time.Hour + 29*time.Minute + 59*time.Second) + r.dispatch(t) + if len(r.moderate) != 1 { + t.Fatalf("a held submission was checked before midnight: %v", r.moderate) + } + if _, attempts, _, _ := r.row(held); attempts != 0 { + t.Fatalf("attempts %d", attempts) + } + r.clk.advance(time.Second) + r.dispatch(t) + if len(r.moderate) != 2 { + t.Fatalf("the share did not renew at 00:00 UTC: %v", r.moderate) + } + if status, _, _, _ := r.row(held); status != "indexed" { + t.Fatalf("%s after midnight", status) + } + if rows := r.count(`SELECT count(*) FROM moderation_usage`); rows != 1 || r.usage("alice") != 1 { + t.Fatalf("%d usage rows, alice %d: yesterday's count was kept", rows, r.usage("alice")) + } +} + +func TestTheCountSurvivesARestart(t *testing.T) { + r := newShareRig(t, 2) + r.add("alice", "https://example.com/one") + r.add("alice", "https://example.com/two") + r.dispatch(t) + cfg := r.s.cfg + cfg.Work, cfg.Shared, cfg.Environment = false, nil, "" + if err := r.s.Close(); err != nil { + t.Fatal(err) + } + reopened, err := Open(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { reopened.Close() }) + reopened.now = r.clk.now + setTestPage(reopened, `Technical documentation
This educational page explains programming tools, reliable systems, and defensive software engineering. It contains useful public documentation for developers.
`) + r.s = reopened + r.add("alice", "https://example.com/three") + r.add("bob", "https://example.com/four") + r.clk.advance(time.Hour) + r.dispatch(t) + if len(r.moderate) != 3 || r.usage("alice") != 2 { + t.Fatalf("moderate %v, alice used %d", r.moderate, r.usage("alice")) + } + if status, attempts, next, _ := r.row("https://example.com/three"); status != "pending" || attempts != 0 || next != nextMidnight.Unix() { + t.Fatalf("three: %s attempts=%d next=%d", status, attempts, next) + } +} + +func TestCitedSourcePushesCountAgainstTheContributorsShare(t *testing.T) { + r := newShareRig(t, 2) + w := r.post("/api/submissions", sharedTestToken, map[string]any{"urls": []string{"https://docs.example.org/a", "https://docs.example.org/b", "https://docs.example.org/c"}}, nil) + if w.Code != http.StatusAccepted { + t.Fatalf("%d %s", w.Code, w.Body) + } + r.add("bob", "https://example.org/other") + r.dispatch(t) + if r.usage(r.userID) != 2 || r.usage("bob") != 1 || len(r.moderate) != 3 { + t.Fatalf("contributor used %d, bob %d, moderate %v", r.usage(r.userID), r.usage("bob"), r.moderate) + } + if held := r.count(`SELECT count(*) FROM submissions WHERE user_id=? AND status='pending' AND attempts=0 AND next_attempt=?`, r.userID, nextMidnight.Unix()); held != 1 { + t.Fatalf("%d of the contributor's pushes held to midnight, want 1", held) + } +} + +func TestOnlyAModelCheckCountsAgainstTheShare(t *testing.T) { + r := newShareRig(t, 1) + delivered := 0 + r.engine.answer(func(call engineRequest) (int, string) { + switch call.path { + case "/v1/community/moderate": + r.mu.Lock() + r.moderate = append(r.moderate, "") + r.mu.Unlock() + return 200, allowVerdict + case "/v1/community/enqueue": + delivered++ + if delivered == 1 { + return 502, `{}` + } + return 200, enqueueOK + } + return 404, `{}` + }) + r.add("alice", "https://example.com/one") + r.dispatch(t) + r.s.db.Exec(`UPDATE submissions SET next_attempt=0`) + r.dispatch(t) + if status, _, _, _ := r.row("https://example.com/one"); status != "indexed" || len(r.moderate) != 1 || r.usage("alice") != 1 { + t.Fatalf("%s after %d checks, %d counted", status, len(r.moderate), r.usage("alice")) + } +} + +func TestTheGlobalHoldSpendsNoShare(t *testing.T) { + r := newShareRig(t, 5) + r.moderated = func() (int, string) { return 503, ceilingBody } + r.add("alice", "https://example.com/one") + r.dispatch(t) + r.add("bob", "https://example.org/two") + r.dispatch(t) + if len(r.moderate) != 1 || r.usage("alice") != 1 || r.usage("bob") != 0 { + t.Fatalf("moderate %v, alice %d, bob %d", r.moderate, r.usage("alice"), r.usage("bob")) + } +} + +func TestTheDefaultShareIsOneHundredAChecksPerAccountEachDay(t *testing.T) { + r := newCeilingRig(t) + r.moderated = func() (int, string) { return 200, allowVerdict } + if r.s.cfg.ModerationsPerAccount != 100 { + t.Fatalf("default share %d", r.s.cfg.ModerationsPerAccount) + } + for i := 0; i < 101; i++ { + r.add("alice", fmt.Sprintf("https://example.com/p%03d", i)) + } + for i := 0; i < 8; i++ { + r.dispatch(t) + } + if len(r.moderate) != 100 || r.count(`SELECT count(*) FROM submissions WHERE status='pending'`) != 1 { + t.Fatalf("%d checks", len(r.moderate)) + } +} + +func TestOpenValidatesTheShare(t *testing.T) { + for _, tc := range []struct { + share int + ok bool + }{{-1, false}, {0, true}, {1, true}, {100, true}, {10000, true}, {10001, false}} { + s, err := Open(Config{DataDir: t.TempDir(), Backend: "http://127.0.0.1:1", PublicURL: "http://localhost:7780", BrokerKey: testBrokerKey, ModerationsPerAccount: tc.share}) + if err == nil { + s.Close() + } + if (err == nil) != tc.ok || (err != nil && !strings.Contains(err.Error(), "moderations per account")) { + t.Errorf("share %d: %v", tc.share, err) + } + } +} + +func TestAnOlderDatabaseGainsTheUsageTable(t *testing.T) { + r := newShareRig(t, 1) + if _, err := r.s.db.Exec(`DROP TABLE moderation_usage`); err != nil { + t.Fatal(err) + } + cfg := r.s.cfg + cfg.Work, cfg.Shared, cfg.Environment = false, nil, "" + r.s.Close() + reopened, err := Open(cfg) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if ok, err := reopened.reserveModeration(t.Context(), "alice"); !ok || err != nil { + t.Fatalf("reserve on a migrated database: %v %v", ok, err) + } +} diff --git a/internal/community/server.go b/internal/community/server.go index 8054302..8a84641 100644 --- a/internal/community/server.go +++ b/internal/community/server.go @@ -56,6 +56,7 @@ type Config struct { Awards bool // Apply pending credit awards; needs shared accounts and the article cross-check. Work bool // Serve /api/work/* and run the settled, counter, alert and push loops; needs shared accounts and the broker key. FetchDailyCap int // Pages credited per account per UTC day; 0 means the default. + ModerationsPerAccount int // Content checks one account may use per UTC day; 0 means the default. Environment string // "prod" or "staging", the label of the alerts the app raises. Articles ArticleReader TrustedProxies []string @@ -864,6 +865,9 @@ func (s *Server) dispatch(ctx context.Context) error { if err != nil { return err } + if _, err = s.db.ExecContext(ctx, `DELETE FROM moderation_usage WHERE day Date: Fri, 16 Oct 2026 12:47:08 +0300 Subject: [PATCH 112/121] test(cosift): a content check is not made when it cannot be counted --- internal/community/moderation_share_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/community/moderation_share_test.go b/internal/community/moderation_share_test.go index 73f4a83..cf0cf53 100644 --- a/internal/community/moderation_share_test.go +++ b/internal/community/moderation_share_test.go @@ -227,3 +227,16 @@ func TestAnOlderDatabaseGainsTheUsageTable(t *testing.T) { t.Fatalf("reserve on a migrated database: %v %v", ok, err) } } + +func TestACheckIsNotMadeWhenItCannotBeCounted(t *testing.T) { + r := newShareRig(t, 5) + if _, err := r.s.db.Exec(`CREATE TRIGGER no_usage BEFORE INSERT ON moderation_usage BEGIN SELECT RAISE(ABORT, 'full'); END`); err != nil { + t.Fatal(err) + } + r.add("alice", "https://example.com/one") + r.dispatch(t) + status, attempts, next, _ := r.row("https://example.com/one") + if len(r.moderate) != 0 || status != "pending" || attempts != 1 || next == nextMidnight.Unix() { + t.Fatalf("moderate %v, %s attempts=%d next=%d", r.moderate, status, attempts, next) + } +} From ede5f59d698b224cf01df6325aa852a49830f099 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 11:10:00 +0300 Subject: [PATCH 113/121] docs: ENV.md lists the fetch-work and community work variables --- docs/ENV.md | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/ENV.md b/docs/ENV.md index 54299a2..857a8b0 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -37,7 +37,7 @@ about what each "token/key" var actually does: | Var | What it really is | Secret? | |---|---|---| -| `COSIFT_ADMIN_TOKEN` | **CLIENT-side** Bearer for the `cosift admin …` subcommands (`cmd_admin.go`) — the value the *CLI* sends to a remote box's `/admin/*`. It is **NOT** the server's auth gate. | Yes (client credential) | +| `COSIFT_ADMIN_TOKEN` | **CLIENT-side** Bearer for the `cosift admin …` and `cosift work …` subcommands (`cmd_admin.go`, `cmd_work.go`) — the value the *CLI* sends to a remote box's `/admin/*`. It is **NOT** the server's auth gate. | Yes (client credential) | | `COSIFT_EMBED_API_KEY` / `COSIFT_CHAT_API_KEY` | Provider API keys for the embed / chat slots (`resolveAPIKey`). Fall back to `OPENAI_API_KEY` → `OPENAI`. | Yes | | `OPENAI_API_KEY` / `OPENAI` | OpenAI(-compatible) key; `OPENAI` is the accepted short form. | Yes | | `COHERE_API_KEY` / `VOYAGE_API_KEY` | Rerank provider keys (HTTP reranker, when `cfg.Rerank.URL` is set and `cfg.Rerank.APIKey` is empty). | Yes | @@ -86,7 +86,7 @@ not re-enable them without the confidentiality decision in that section. | Variable | Type | Default | Effect | Where read | |---|---|---|---|---| -| `COSIFT_ADMIN_TOKEN` | string (SECRET, client-side) | unset → no Bearer sent | Token the `cosift admin …` CLI subcommands send as the admin Bearer to a remote box. **Not** a server gate. | `cmd/cosift/cmd_admin.go:96,135,332,415,537`; `cmd_maintenance.go:618` | +| `COSIFT_ADMIN_TOKEN` | string (SECRET, client-side) | unset → no Bearer sent | Token the `cosift admin …` and `cosift work …` CLI subcommands send as the admin Bearer to a remote box. **Not** a server gate. | `cmd/cosift/cmd_admin.go:96,135,332,415,537`; `cmd_maintenance.go:618`; `cmd/cosift/cmd_work.go:48-50` | | `COSIFT_EMBED_API_KEY` | string (SECRET) | unset → fall back to `OPENAI_API_KEY` → `OPENAI` → anonymous | API key for the **embed** slot. | `cmd/cosift/main.go:359` (`resolveAPIKey("embed")`) | | `COSIFT_CHAT_API_KEY` | string (SECRET) | unset → fall back to `OPENAI_API_KEY` → `OPENAI` → anonymous | API key for the **chat** slot. | `cmd/cosift/main.go:361` (`resolveAPIKey("chat")`) | | `OPENAI_API_KEY` | string (SECRET) | unset → try `OPENAI` → anonymous | OpenAI(-compatible) key; common fallback for both embed and chat slots, and read directly by `index` / `eval` / `serve` subcommands. | `main.go:368`; `cmd_index.go:155,612`; `cmd_eval.go:272,335,356,375,1316`; `cmd_serve.go:136`; `cmd_maintenance.go:562,609,610` | @@ -259,8 +259,11 @@ not re-enable them without the confidentiality decision in that section. The engine serves the community service's two routes, `POST /v1/community/enqueue` and `POST /v1/community/moderate`, on its `/v1` listener, for a key principal with -the `community:intake` scope. The moderation ceiling is the `work` block of -`cosift.json`, not an env var (see [docs/COMMUNITY.md](COMMUNITY.md)). +the `community:intake` scope. With fetch work on (see [Fetch work](#fetch-work)), +a key principal with the `work:broker` scope also reaches `/v1/work/claim`, +`/v1/work/submit`, `/v1/work/push` and `GET /v1/work/settled`. The moderation +ceiling and the fetch-work limits are the `work` block of `cosift.json`, not env +vars (see [docs/COMMUNITY.md](COMMUNITY.md)). | Variable | Type | Default | Effect | Where read | |---|---|---|---|---| @@ -272,12 +275,33 @@ The `cosift community` service reads these (set them in its environment file; | Variable | Type | Default | Effect | Where read | |---|---|---|---|---| -| `COSIFT_COMMUNITY_BROKER_KEY` | string (SECRET) | unset → submissions wait as a configuration error | The community principal's `csk_` key for the engine's `/v1` routes. Sent only to `COSIFT_COMMUNITY_ENGINE_URL`. Must differ from `COSIFT_WIKI_ENGINE_KEY`. | `cmd/cosift/community.go:72`; `internal/community/engine.go:37` | -| `COSIFT_COMMUNITY_ENGINE_URL` | string (URL) | `http://127.0.0.1:7779` | The engine's `/v1` listener; must be a loopback `http` origin. | `cmd/cosift/community.go:72`; `internal/community/engine.go:37` | +| `COSIFT_COMMUNITY_BROKER_KEY` | string (SECRET) | unset → submissions wait as a configuration error | The community principal's `csk_` key for the engine's `/v1` routes. Sent only to `COSIFT_COMMUNITY_ENGINE_URL`. Must differ from `COSIFT_WIKI_ENGINE_KEY`. | `cmd/cosift/community.go:76`; `internal/community/engine.go:37` | +| `COSIFT_COMMUNITY_ENGINE_URL` | string (URL) | `http://127.0.0.1:7779` | The engine's `/v1` listener; must be a loopback `http` origin. | `cmd/cosift/community.go:76`; `internal/community/engine.go:37` | | `COSIFT_COMMUNITY_ADMIN_TOKEN` | — | must be unset | No longer used: the service refuses to start while it is set. | `cmd/cosift/community.go:44` | -| `COSIFT_COMMUNITY_CONTRIBUTION_REWARD` | `on` / `off` | `off` | Pays the contribution reward for new content. Anything else stops startup. | `cmd/cosift/community.go:113` | -| `COSIFT_COMMUNITY_CONTRIBUTION_REWARD_DAILY_CAP` | int | `10` | Rewards per account per rolling 24 hours; an integer from 1 to 10000. | `cmd/cosift/community.go:121` | -| `COSIFT_COMMUNITY_AWARDS` | `0` / `1` | `0` | `1` starts the poller that applies pending credit awards every 30 s. Needs shared mode and `COSIFT_WIKI_ENGINE_KEY`. Anything else stops startup. | `cmd/cosift/community.go:102`; `internal/community/awards.go:403` | +| `COSIFT_COMMUNITY_CONTRIBUTION_REWARD` | `on` / `off` | `off` | Pays the contribution reward for new content. Anything else stops startup. | `cmd/cosift/community.go:136` | +| `COSIFT_COMMUNITY_CONTRIBUTION_REWARD_DAILY_CAP` | int | `10` | Rewards per account per rolling 24 hours; an integer from 1 to 10000. | `cmd/cosift/community.go:144` | +| `COSIFT_COMMUNITY_AWARDS` | `0` / `1` | `0` | `1` starts the poller that applies pending credit awards every 30 s. Needs shared mode and `COSIFT_WIKI_ENGINE_KEY`. Anything else stops startup. | `cmd/cosift/community.go:106`; `internal/community/awards.go:403` | +| `COSIFT_COMMUNITY_WORK` | `0` / `1` | `0` | `1` serves `POST /api/work/claim` and `POST /api/work/submit` and runs the settled, counter, alert and push loops. Needs shared mode (`COSIFT_AUTH_MODE=shared`), `COSIFT_COMMUNITY_BROKER_KEY` and `COSIFT_ENV`, or startup fails; anything other than `0` or `1` also stops startup. While it is off both routes answer 404. | `cmd/cosift/community.go:117-124`; `internal/community/work_identity.go:84-97`; `internal/community/work_routes.go:39-42` | +| `COSIFT_COMMUNITY_FETCH_DAILY_CAP` | int | `500` | Pages credited per account per UTC day; an integer from 1 to 10000, anything else stops startup. An account at its cap gets 429 `daily_cap` from the claim route. | `cmd/cosift/community.go:125-131`; `internal/community/limits.go:32-37`; `internal/community/work_routes.go:21,51-56,153-166` | +| `COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT` | int | `100` | Content checks per account per UTC day; an integer from 1 to 10000, anything else stops startup. An account at its share waits until 00:00 UTC without using attempts; other accounts go on. | `cmd/cosift/community.go:144-150`; `internal/community/limits.go:38-41`; `internal/community/moderation.go:83` | +| `COSIFT_ENV` | `prod` / `staging` | unset | The environment label on the alerts the app raises. Required, as one of these two values, when `COSIFT_COMMUNITY_WORK=1`; nothing else reads it. | `cmd/cosift/community.go:132`; `internal/community/work_identity.go:94-95`; `internal/community/work_alerts.go:51` | + +--- + +## Fetch work + +The engine's work pool leases blocked pages to community workers and indexes what +they verify. It runs inside the in-process crawler, so `pebble-serve` needs +`-crawl-seeds-file`; until the crawler is up the `/v1/work/*` routes answer 503. These +two variables are read at `pebble-serve` startup; any value other than `0` or `1` stops +it. The limits (lease time, quorum window, daily moderation +ceiling and the rest) are the `work` block of `cosift.json`, listed in +[docs/COMMUNITY.md](COMMUNITY.md#fetch-work). + +| Variable | Type | Default | Effect | Where read | +|---|---|---|---|---| +| `COSIFT_WORK_ENABLED` | `0` / `1` | `0` | `1` starts the pool feeders and the sweeper, serves the `/v1/work/*` routes and adds the `work` block to `/stats` and the `cosift_work_*` series to `/metrics`. While it is `0` the `/v1/work/*` routes answer 503 `work_disabled`; `/admin/work-purge` and `/admin/work-void` still answer. | `internal/config/work.go:118`; `cmd/cosift/serve_v1_work.go:41,58,117-120,284,290`; `cmd/cosift/serve_work_admin.go:69-75`; `cmd/cosift/serve_setup.go:67,722` | +| `COSIFT_WORK_FRONTIER` | `0` / `1` | `1` | `0` stops the feeder that offers queued frontier rows to workers; pushed URLs and blocked (errored) rows are still offered. Matters only with `COSIFT_WORK_ENABLED=1`. | `internal/config/work.go:121`; `cmd/cosift/serve_v1_work.go:45`; `internal/work/feed.go:101-103` | --- From 60f9187e50d608ef92f7e53a34d35dc688460a8e Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 11:13:39 +0300 Subject: [PATCH 114/121] docs: COMMUNITY.md covers fetch work, article contributions and the public article pages --- docs/COMMUNITY.md | 247 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 231 insertions(+), 16 deletions(-) diff --git a/docs/COMMUNITY.md b/docs/COMMUNITY.md index a821728..4e64577 100644 --- a/docs/COMMUNITY.md +++ b/docs/COMMUNITY.md @@ -13,6 +13,8 @@ People can: - Submit public webpage URLs in a multiline field or a CSV upload after signing in. - See their most recent 200 contributions, indexing status and credit balance. - Submit the same URLs or CSV files using `cosift contribute`, optionally extracting text and computing embeddings locally. +- Fetch pages that sites block for the crawler, from their own network, with `cosift contribute -worker` (see [Fetch work](#fetch-work)). +- Open **My contributions** to see the articles written with their agent, fetched-page counters, credits earned and submitted pages. - Follow topics across the web and MCP, check article coverage, and record requests for missing articles. - View monthly credits and manage an optional paid subscription or subscriber top-up when live payments are configured. @@ -62,11 +64,12 @@ required for the community service. The community service reaches the backend's loopback `/v1` listener (default `http://127.0.0.1:7779`) with its own scoped key, not the peer token. Create a key principal in the backend's service-auth file with the `community:intake` -scope alone (community scopes combine only with `work:broker`, and require `"env": "prod"`), +scope (add `work:broker` when fetch work is on; these two scopes combine only with +each other, and require `"env": "prod"`), then set `COSIFT_COMMUNITY_BROKER_KEY` to its `csk_` key in the community service environment. Set `COSIFT_COMMUNITY_ENGINE_URL` only when the listener is elsewhere; it must be a loopback `http` origin. The service uses the key only for contribution -checks and delivery; it never sends it to the browser or with searches. It refuses +checks and delivery (and, with fetch work on, the work routes); it never sends it to the browser or with searches. It refuses to start while `COSIFT_COMMUNITY_ADMIN_TOKEN` is set, and the backend's `cluster.peer_auth_token` must never be given to the community service. @@ -92,7 +95,7 @@ not proxy arbitrary backend paths or expose backend administration. ## Contribution delivery -The app immediately rejects known adult domains, private/non-web URLs, and executable download links. A durable queue in the community database holds submissions while a worker checks public page content and calls the engine's `POST /v1/community/moderate`, authenticated with the broker key. Obvious parked/placeholder domains, error/bot/login pages, and extreme repetitive filler are stopped before model classification. The classifier additionally rejects spam, link farms, SEO doorway pages, incoherent scraps and content without useful information. It must preserve useful code, non-English pages, medical education and academic research; authorship alone is not a rejection signal. These checks reduce junk but do not guarantee perfect classification. Only an explicit safe result permits delivery to `POST /v1/community/enqueue`. +The app immediately rejects known adult domains, private/non-web URLs, and executable download links. A durable queue in the community database holds submissions, and the dispatch loop takes them in rotation across accounts, oldest first within an account, so one account's backlog delays another's submission by one job at most. A worker checks public page content and calls the engine's `POST /v1/community/moderate`, authenticated with the broker key. Obvious parked/placeholder domains, error/bot/login pages, and extreme repetitive filler are stopped before model classification. The classifier additionally rejects spam, link farms, SEO doorway pages, incoherent scraps and content without useful information. It must preserve useful code, non-English pages, medical education and academic research; authorship alone is not a rejection signal. These checks reduce junk but do not guarantee perfect classification. Only an explicit safe result permits delivery to `POST /v1/community/enqueue`. The engine caps the model calls of the moderation route per UTC day. Set `work.community_moderations_per_day` in `cosift.json` (default and maximum 1000; @@ -102,9 +105,16 @@ The engine caps the model calls of the moderation route per UTC day. Set { "work": { "community_moderations_per_day": 500 } } ``` -Past the ceiling the route answers 503 `moderation_unavailable` with `Retry-After` -set to the next 00:00 UTC. A page decided before the model (adult, obvious junk) -takes nothing from the allowance. The community app does not wait for the reset: a refused check counts as an attempt, so while the route is halted or past its ceiling, submissions that need the model end as `unverified` after 12 attempts, about two hours. `/metrics` counts the answers as +Past the ceiling the route answers 503 `moderation_unavailable` with `ceiling: true` +and `Retry-After` set to the next 00:00 UTC. A page decided before the model (adult, +obvious junk) takes nothing from the allowance. On that answer the community app sends +no further content check until 00:00 UTC: submissions that need the model stay +`pending` with the reason "Waiting for the daily content-check allowance to renew." +and do not use up attempts. Each account also has a daily share of the content checks, +`COSIFT_COMMUNITY_MODERATIONS_PER_ACCOUNT` (default 100; an integer from 1 to 10000, +anything else stops startup): once an account has used it, that account's submissions wait +until 00:00 UTC without using attempts, while other accounts' submissions go on. Any other +model failure still counts as an attempt, and a submission ends as `unverified` after 12. `/metrics` counts the answers as `cosift_community_moderations_total{result="ok|ceiling|error"}`. Each community route also accepts at most 30 calls a minute per principal; lower them with `COSIFT_V1_ENQUEUE_PER_MIN` and `COSIFT_V1_MODERATE_PER_MIN` on the engine (an @@ -129,8 +139,11 @@ Authenticated CLI users can fetch, parse, chunk and embed webpages locally, save ./cosift request -server https://cosift.pilotprotocol.network -mode research -query "How does Raft work?" ``` -`-index-locally` also accepts `-csv`. It requires login, limits an artifact to -32,000 text bytes and 64 chunks, and retains the total 1 MB request limit. +`-index-locally` also accepts `-csv`. It fetches each page the way a browser does +(browser request headers, a public-only dialer, each redirect hop checked) +and extracts it with the same code as `cosift contribute -worker`. It requires login, +limits an artifact to 32,000 text bytes and 64 chunks, and retains the total 1 MB +request limit. The backend fetches the source independently, compares title/text, checks the model/dimensions, and verifies **every vector** against its own embedding model before reuse. Failed verification cannot inject vectors. This first version @@ -182,7 +195,7 @@ backend. Insufficient credit rejects the request before backend work with HTTP 4 add credits when billing is available, contribute approved new content when rewards are enabled, or wait for the next monthly grant. Reading a balance or managing saved requests does not itself spend retrieval credits. In the monthly -activity, `earned` counts contribution rewards, awards and royalties, and +activity, `earned` counts contribution rewards, awards, royalties and page-fetch credits, and `spent` counts retrieval debits, article reads and clawbacks. In shared mode, `POST /api/meter` charges a covered article read to the bearer @@ -195,11 +208,209 @@ account. `COSIFT_COMMUNITY_AWARDS=1` (default `0`) starts a poller that every 30 seconds applies pending credit awards (contribution awards, royalties and clawbacks) -from the shared account store to the ledger, once per award. It needs shared +from the shared account store to the ledger, once per award. A clawback is applied +to the account's linked community user even while the account is suspended or +unverified, and it is never given up on; other awards that stay unresolvable for 7 days +are skipped. It needs shared mode and `COSIFT_WIKI_ENGINE_KEY`, which it uses to confirm each article with the engine, and the service refuses to start when that key equals `COSIFT_COMMUNITY_BROKER_KEY`. +## Fetch work + +Some sites turn the crawler away: HTTP 403 or 429, a bot-check page, a timeout. Fetch +work lets signed-in contributors fetch such pages from their own network and has the +engine index what is verified. It is off until the engine runs with +`COSIFT_WORK_ENABLED=1` (inside the in-process crawler, so with `-crawl-seeds-file`) +and the community app with `COSIFT_COMMUNITY_WORK=1`; see +[ENV.md](ENV.md#fetch-work). While the engine's switch is off its `/v1/work/*` routes +answer 503 `work_disabled`; while the app's is off its `/api/work/*` routes answer 404. + +**The pool.** The engine offers workers URLs from three sources, in this order: URLs the +community app pushes (`pushed`), crawl-frontier rows whose last error was a block (HTTP +403 or 429, a rate limit, challenge or captcha, a bot check, a timeout; `errored`), and +queued frontier rows (`frontier`; off with `COSIFT_WORK_FRONTIER=0`, held to half of +`pool_max`). Hosts take turns within a source, and a host is leased at most once per +`host_spacing_ms`. The crawler's domain policy and the adult filter apply to every URL, +and a URL already in the corpus is never offered. With fetch work on, the app also +pushes, every 5 minutes, up to 100 pending or unverified submissions whose page its own +check could not fetch because the site blocked it, none of them offered in the last 24 +hours. + +**Leases.** A claim returns up to 32 leases (`max`, default 1). A lease lasts +`lease_ttl_s`. A URL is leased to at most `distinct_leases_per_url` workers at once, and a +worker and a network each get a URL once per `attestation_ttl_h`. The app derives the +worker id from the account, and the network id from the client address (its IPv4 /24 or +IPv6 /48), with salts kept in the community database; a client never sends either. A URL +that `max_attempts` worker and network pairs failed to deliver, or that three pairs +reported as failures, is parked for `attestation_ttl_h`. + +**Attestations.** For a 200 the worker submits the page's title, text, language, content +hash and simhash with the extraction version, and the engine recomputes both hashes. Any +other `http_status` is a failure report that returns the lease. Two attestations agree +when their simhashes differ by at most `simhash_max` bits and they come from different +workers on different networks. A page from an `errored` URL is verified by two agreeing +attestations that also agree on title, language and enough of the text, and the text +they agree on is what gets indexed. The engine also fetches the page itself when +`quorum_window_s` ends, and at the next sweep when two attestations of a `pushed` or +`frontier` URL agree. If that copy is within `simhash_max` of an attesting group, the page counts as +verified by the engine; otherwise every attesting worker is a mismatch and the engine's +own copy is used instead. Either way the page then goes through the checks below. A +server fetch that fails, or returns an error or bot-check page, is retried after an hour, +doubling to a day. A record ends after `attestation_ttl_h`. + +**Checks and the ceiling.** A verified text passes the adult filter, the quality +prefilter and the content-safety model before it is indexed. Each model call takes one +unit of `work_moderations_per_day`. At that ceiling a claim returns no leases and +`moderation_ceiling: true` with `retry_after_s` (at most an hour), and verified pages +wait as `deferred` until after it; a page the model fails on `moderation_attempts_max` times is +rejected. Indexed pages report `source: "attested"` and `verified_by` (`quorum` or +`server`) in `/v1/contents`; crawled pages report `source: "crawl"`. + +**Routes.** All four need a key principal with the `work:broker` scope (add it to the +community principal; see [Start the services](#start-the-services)): + +| Method and path | Body / behavior | +| --- | --- | +| `POST /v1/work/claim` | `{worker, net, max}` (ids are 32 hex characters); returns `{leases:[{lease_id,url,source,expires_at,extract_version}], retry_after_s?, moderation_ceiling?}` | +| `POST /v1/work/submit` | A lease's attestation or failure report, up to 2 MiB; returns `{verdict, agreeing, indexed, window_expires_at?, reason?}` with `verdict` one of `awaiting_quorum`, `verified`, `mismatch`, `deferred`, `rejected`, `released` | +| `POST /v1/work/push` | `{urls:[...]}`, 1 to 100; returns `{accepted, duplicates, rejected:[{index,rule}]}` with `rule` one of `url`, `policy`, `pool_full` | +| `GET /v1/work/settled` | `?after=SEQ&limit=1-200` (default 100); returns settled outcomes (`verified`, `mismatch`, `rejected`, `expired`) with the workers each concerns, for the app to credit | + +A refusal carries the `/v1` error envelope: 409 `lease_expired`, `lease_not_owner` or +`extract_version_mismatch` (with the engine's `extract_version`), 422 `invalid_field` +with the `field` and `rule`. + +**The `work` block of `cosift.json`.** Every key is optional; a value outside its range +stops `pebble-serve` at startup naming the key. + +| Key | Default | Range | Effect | +| --- | --- | --- | --- | +| `lease_ttl_s` | 600 | 60-3600 | Seconds a lease lasts | +| `max_leases_per_worker` | 32 | 1-32 | Leases one worker holds at once | +| `max_leases_total` | 2048 | 32-8192 | Leases held across all workers | +| `distinct_leases_per_url` | 3 | 2-3 | Workers a URL is leased to at once | +| `host_spacing_ms` | 2000 | 0-60000 | Minimum gap between leases of one host | +| `quorum_window_s` | 1800 | 60-86400 | Wait after the first attestation before the engine fetches the page itself | +| `simhash_max` | 6 | 0-14 | Largest simhash distance that counts as agreement | +| `attestation_ttl_h` | 168 | 24-168 | Hours a record, a park and a worker or network exclusion last | +| `work_moderations_per_day` | 1000 | 0-1000 | Daily model calls for fetched pages; 0 halts them | +| `community_moderations_per_day` | 1000 | 0-1000 | The community moderation ceiling above | +| `max_attempts` | 6 | 1-20 | Failed worker and network pairs before a URL is parked | +| `moderation_attempts_max` | 5 | 1-10 | Failed model checks before a page is rejected | +| `pool_max` | 20000 | 100-200000 | URLs in the pool | +| `errored_scan_interval_s` | 60 | 5-3600 | Seconds between feeder passes | +| `errored_scan_batch` | 500 | 10-5000 | Frontier rows read per feeder pass | +| `refetch_concurrency` | 2 | 1-8 | Server fetches at once | +| `moderation_concurrency` | 2 | 1-4 | Model checks at once | +| `moderation_queue_max` | 256 | 8-2048 | Verified texts waiting for a check; at the limit, new verifications pause | +| `sweep_interval_s` | 30 | 5-600 | Seconds between sweeps of leases, records and due work | + +**The app.** `POST /api/work/claim` (`{max}`, 1 to 32) and `POST /api/work/submit` take +only a bearer token (`Authorization: Bearer ck_...`; no header answers 401 +`bearer_required`) and, like every bearer POST, `X-Cosift-Client: community`. The account +must have joined the contributor network and accepted the current +[contributor terms](#public-article-pages): otherwise 403 `not_joined`, `banned` or +`terms_outdated`. An account is limited to 6 claims and 64 submissions a minute and to +`COSIFT_COMMUNITY_FETCH_DAILY_CAP` credited pages per UTC day (429 `daily_cap`, with +`Retry-After` to 00:00 UTC). The submit body may carry only the attestation members; it is +capped at 2 MiB. A verified page whose content was new earns the account 2 credits, once +per page, until its daily cap. The app reads the settled feed every 10 seconds, writes +each contributor's claimed, submitted, verified, mismatch, rejected and credit counters +every 60 seconds (they show under **My contributions**), and raises a +`work_moderation_ceiling` alert, labelled with `COSIFT_ENV`, the first time in a UTC day +that the engine reports each ceiling (fetch work, community moderation). + +**The CLI worker.** + +```sh +cosift contribute -server https://cosift.pilotprotocol.network -worker -max-pages 100 -duration 30m -concurrency 4 +``` + +`-max-pages` (default 100) and `-duration` (default 30m) stop the run; 0 removes a limit +but at least one must remain. `-concurrency` (default 4, 1 to 32) is the number of leases +in flight. These three flags need `-worker`, and `-worker` cannot be combined with +another operation. It authenticates with the bearer token from `COSIFT_TOKEN` or a saved +session (the installer's, or `-session-file`) that holds one; a cookie-only session or a +password login is refused before any request. Each leased page is fetched as a browser +would (browser request headers, a public-only dialer, each redirect hop checked) and extracted with the same code as +`-index-locally`; a non-200 answer is reported as a failure, and a page too short, too +long or unreadable is skipped. The worker prints one JSON object per line on stdout (events +`claimed`, `submitted`, `reported`, `skipped`, `abandoned`, `waiting`, `lease_lost` and +`stopped`, then a `summary`) and stops at its limits, when no work is offered after 10 polls, at a +moderation ceiling or the daily cap, or on a refusal it cannot get past (a rejected +token, an account that has not joined, is suspended or has outdated terms, or an +extraction version the server does not share, which needs a newer `cosift`). A stop for +a refusal exits non-zero, and so does `unavailable` (a network error or a 5xx answer, +including `work_disabled` when the engine's work switch is off, that persists through 5 +tries) and `not_available` (a server without worker mode); the other stops exit normally. + +**Operator commands.** `cosift work purge --url URL` and `cosift work void --worker ID` +call the engine's `POST /admin/work-purge` and `POST /admin/work-void`, behind the admin +gate (`cluster.peer_auth_token`). Both are dry runs until `-y`; `-server`, `-token` (default +`COSIFT_ADMIN_TOKEN`) and `-json` are as for `cosift admin`. Purge deletes the attested +document stored under the URL and its final URL (a crawled document is kept and +counted), the URL's record and text, parks the URL for `attestation_ttl_h` and settles every +attesting worker as rejected. Void takes one worker's attestations out of every record +still awaiting, regroups the rest, and parks a URL left without an agreeing pair; the +worker id is the one in the app's `work_workers` table. The engine's `/stats` gains a +`work` block (pool by source, leased, awaiting, verified, mismatch, rejected, expired, +deferred, parked, `moderations_today`, `moderation_ceiling`, `settled_seq`) and +`/metrics` the `cosift_work_*` series, while work is on. + +## Article contributions + +The engine takes article writes from a contributing service principal. Give an OIDC +principal in the service-auth file the `articles:contribute` scope in place of +`articles:write`; it cannot be combined with `articles:write` or `articles:stub`, and a +key principal cannot hold it. Its `PUT /v1/articles/{id}` calls draw on the same +`writes_per_hour`, `writes_burst` and `writes_per_day` budget as any writer (defaults 30, +10 and 150). A contribution must carry `contributor` (the 16-hex contributor id), have +`status` `published` and `sensitivity.class` `none`, and carry no `topic_ids` or +`cluster_ids`. Updating an existing article also needs `expected_version` and a quality +tier no lower than the stored one (409 `tier_regression`); an article that is held, +rejected or tombstoned refuses it, and one article takes at most 64 contributors (409 +`contributors_full`). + +Article records and their public view carry `origin` (`synth`, or `community` once a +contribution has been written), `contributors` (the ids, in the order they first +contributed) and, in the full record, `version_contributor` (who wrote the current +version). A community article keeps its last 20 versions, other articles 5, and +`GET /v1/article-versions/{id}` adds `status`, `sensitivity_class`, `version_contributor` +and `reverts_to` to each version. + +A principal with `articles:moderate` can call `POST /v1/articles/{id}/revert` with +`{to_version, reason_code, by, request_id?}`. It writes the content of an earlier, +published, sensitivity-`none` version as a new version of a published article, and +answers `{result, article, to_version, reverted_versions, reverted_contributors}` with +`result` `reverted` or, when the content is already current, `unchanged`; repeating a +`request_id` replays the answer with `replayed: true`. A `tombstone` through +`POST /v1/articles/{id}/status` returns the article's `contributors`. +`GET /v1/articles` accepts `tier=` (comma-separated `strong`, `ok`, `thin`) and, for +`articles:read`, `status=pending` to list stubs (`status` takes a comma-separated list; +`articles:read` may name only `published` and `pending`). + +## Public article pages + +With `COSIFT_WIKI_PUBLIC=1` (see `deploy/community.env.example`) the app serves the +engine's published articles under `/wiki`, with the sitemaps and `robots.txt`, and: + +- `/wiki/.md`: the article as Markdown. Only published articles have one. +- `/llms.txt`: the promoted articles as links to their Markdown form, with links to the + style guide, the terms and the wanted list. +- `/wiki/style` and `/wiki/style.md`: the contributor style guide; `/wiki/terms`: the + contributor terms, which a contributor must have accepted (see [Fetch work](#fetch-work)); + `/wiki/wanted`: up to 200 pending stubs (generic title and vertical only) and up to 200 + published articles of `thin` quality, cached for 5 minutes. The slugs `style`, + `terms` and `wanted` are reserved. + +Every article page and Markdown file carries the CC BY-SA 4.0 licence line. A +community-origin article also names its contributors, as `@handle` from the shared +account service; a banned contributor, one the service does not know, and every +contributor when handles cannot be read (local mode, or the lookup failing) appear as +"a community member". A page built while the lookup failed is cached only briefly. +`deploy/Caddyfile.community` routes `/llms.txt` to the app with the other public pages. + ## Plans and payments The **Free plan requires no subscription** and includes the monthly 1,000 credits. @@ -326,6 +537,9 @@ enabled. CLI clients may omit Origin. Login returns an HttpOnly session cookie n | `POST /api/payments/checkout` | Member | `{kind:"subscription"\|"topup",idempotency_key}`; returns a hosted Stripe Checkout URL | | `POST /api/payments/portal` | Member | `{}`; returns an existing subscriber's restricted billing portal URL | | `POST /api/payments/webhook` | Stripe signature | Paid invoice/top-up fulfillment, subscription state, and refund reconciliation | +| `GET /api/contributions` | Member | `{joined}`; for a contributor also `handle`, the newest 50 `contributions` (articles with status, credits and article link, refusals, lost reason, sources to fetch), `more`, the `fetch` counters and the `earned` credit rows; 30 calls a minute | +| `POST /api/work/claim` | Bearer token, fetch work on | `{max}`; leases pages to fetch ([Fetch work](#fetch-work)); 6 calls a minute | +| `POST /api/work/submit` | Bearer token, fetch work on | A lease's attestation or failure report, up to 2 MiB; 64 calls a minute | | `POST /api/meter` | Bearer token, shared mode | `{kind:"article",ref,request_id}`; charges one covered article read, once per `request_id` | | `POST /api/shared` | Member, shared mode | Allowlisted topic, article lookup, or article request tool; search uses the gateway retrieval routes | | `GET /api/submissions` | Member | Own recent contributions | @@ -350,16 +564,16 @@ and 600,000 iterations; local sessions store only token hashes and expire after Account queries scope saved searches and submissions to their owner. Guest allowance records use a salted hash of the client network (the IP address, or the /64 for IPv6) rather than a raw IP. Unauthenticated limits are counted per client -network; limits on submissions, checkout, the billing portal, shared tools and -metering are counted per account, in a bounded store that evicts its oldest +network; limits on submissions, checkout, the billing portal, shared tools, metering, +work claims, work submissions and the contributions list are counted per account, in a bounded store that evicts its oldest entry instead of refusing new ones. Failed bearer tokens are throttled per /24 (IPv4) or /64 (IPv6) network after 30 a minute; a token that authenticates is never throttled. Interests provide clickable search suggestions and shared topic follows; they do not alter retrieval ranking. Queries go to the configured backend, whose logging policy applies. Shared production verifies email with a code; standalone mode has no email -verification or self-service password reset. Automatic article authoring and the -distributed contributor network remain separate, unfinished work. +verification or self-service password reset. Automatic article authoring remains +separate, unfinished work. Search returns ordinary result cards; Answer and Research render the returned answer, source citations, and research plan. Their synchronous backend timeout is three minutes; configure reverse proxies to allow at least four minutes. Missing LLM configuration is reported clearly and does not consume the guest allowance. @@ -374,8 +588,9 @@ See [the operator rollout and rollback record](COMMUNITY-ROLLOUT.md) for the con Create its private data directory before starting it and supply `COSIFT_COMMUNITY_BROKER_KEY` through root-owned `/etc/cosift/community.env`; `deploy/community.env.example` lists the variables. -`deploy/Caddyfile.community` routes the root, static assets and `/api/*` to the -portal, and routes public `/search`, `/answer`, and `/research` through the same quotas. All unlisted routes, including `/query`, `/find_similar` and `/contents`, return 404. `/v1/*` goes to the engine's service listener, but a `csk_` bearer is answered 403 at the edge, so the broker key works only over loopback. It trusts only loopback and +`deploy/Caddyfile.community` routes the root, static assets, `/api/*` and the public +article pages (`/wiki`, `/sitemap.xml`, `/robots.txt`, `/llms.txt`) to the portal, and +routes public `/search`, `/answer`, and `/research` through the same quotas. All unlisted routes, including `/query`, `/find_similar` and `/contents`, return 404. `/v1/*` goes to the engine's service listener, but a `csk_` bearer is answered 403 at the edge, so the broker key works only over loopback. It trusts only loopback and Cloudflare networks, then overwrites the forwarded client IP. The community backup timer snapshots SQLite consistently into the existing GCS From 7a4732bd00767de0715fd563b8784c2049bb7e05 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 11:13:58 +0300 Subject: [PATCH 115/121] docs: HANDOVER.md lists the work admin endpoints and routes --- docs/HANDOVER.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 11a0577..e8ab1a0 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -337,17 +337,20 @@ What "the agency gets SSH" actually grants. These are the user-facing API (`cmd/cosift/pebble_serve.go`). Note `/search?q=` and `/answer?q=` carry queries in the URL — the reason §2.5 forbids any front-proxy `log` directive on the box. -### Admin endpoints (24, localhost-only, Bearer-gated by `peer_auth_token`) +### Admin endpoints (26, localhost-only, Bearer-gated by `peer_auth_token`) All under `/admin/*` on `pebble-serve`, e.g.: `crawl-enqueue`, `allow-domain`, `frontier-purge-host`, `frontier-clear`, `frontier-demote-host`, `frontier-purge-stale-inflight`, `rss-import`, `crawl-now`, `wet-import`, `wet-import-bulk`, `site-pack`, `site-submit`, `embed-backfill`, `host-backfill`, `eval-quick`, `hnsw-compact`, `sitemap-import`, `recrawl-sitemap`, `pq-train`, `pq-encode`, `checkpoint`, `query-log`, `feedback`, -`domains-audit`. Plus `/debug/pprof/*`. **Public access to all of these is 404'd by Caddy** (§2.6); -local access is gated by `peer_auth_token` (§3). The community app's two intake routes are not +`domains-audit`, `work-purge`, `work-void` (the last two back `cosift work purge|void`; both are dry runs +unless the body says `"apply": true`). Plus `/debug/pprof/*`. **Public access to all of these is 404'd by Caddy** (§2.6); +local access is gated by `peer_auth_token` (§3). The community app's intake routes are not admin endpoints: they are `POST /v1/community/{enqueue,moderate}` on the loopback `/v1` listener, -gated by a scoped service key (`community:intake`), and a `POST` to any other `/admin/` path is a 404. +gated by a scoped service key (`community:intake`), and its fetch-work routes +(`POST /v1/work/{claim,submit,push}`, `GET /v1/work/settled`, served only with `COSIFT_WORK_ENABLED=1`) +are gated by `work:broker` on the same listener. A `POST` to any other `/admin/` path is a 404. ### Offline subcommands bypass HTTP auth entirely From 9f721d3c0ee52ad812588fb9e94922f8049c4ead Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 11:13:58 +0300 Subject: [PATCH 116/121] docs: API.md describes the work block of /stats and the work metrics --- docs/API.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index f78bc87..f32a4d4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -63,9 +63,11 @@ curl http://127.0.0.1:7777/stats `reranker`, `rerank_candidate_k`, and `chat_model` only appear when the matching capability is configured. `retrievers` lists what actually works on this server right now — `bm25` and `bm25-mlt` always; `dense`/`hybrid` only when the HNSW graph is loaded and an embedder is configured. When the graph is loaded but no embedder, `dense:find_similar_url_only` indicates that only URL-mode `/find_similar?retriever=dense` is available (it reuses the source's persisted vector and doesn't need an embedder). +With fetch work on (`COSIFT_WORK_ENABLED=1`), the body also carries a `work` block: `enabled`, `extract_version`, `pool` (`total`, `pushed`, `errored`, `frontier`), `leased`, `awaiting`, `verified`, `mismatch`, `rejected`, `expired`, `deferred`, `parked`, `moderations_today`, `moderation_ceiling` and `settled_seq`. See [COMMUNITY.md](COMMUNITY.md#fetch-work). + ## `GET /metrics` -Prometheus exposition format. Hand-rolled, no client_golang dep. Covers index counters, HyDE cache, rerank attempts/failures, chat attempts/failures/duration, per-endpoint request count + duration sum. +Prometheus exposition format. Hand-rolled, no client_golang dep. Covers index counters, HyDE cache, rerank attempts/failures, chat attempts/failures/duration, per-endpoint request count + duration sum. With fetch work on it adds `cosift_work_leases_total{source}`, `cosift_work_submissions_total{verdict}`, `cosift_work_settled_total{verdict}`, `cosift_work_moderations_total{result}`, `cosift_work_refetch_total{result}` and the gauge `cosift_work_pool{class}`. ```bash curl http://127.0.0.1:7777/metrics From 30cfa5fec6b55a80411b70d4e95046ff38ff5ff9 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 11:14:14 +0300 Subject: [PATCH 117/121] docs: AGENT-SETUP.md shows the contribute worker and the browser fetch of local indexing --- docs/AGENT-SETUP.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/AGENT-SETUP.md b/docs/AGENT-SETUP.md index 971d2a4..868197b 100644 --- a/docs/AGENT-SETUP.md +++ b/docs/AGENT-SETUP.md @@ -65,7 +65,7 @@ then reload the agent. This does not require uploading a token to the skill file ## Authenticated CLI Use [Cosift v0.2.7 or a newer stable release](https://github.com/pilot-protocol/cosift/releases/latest) -for your OS and architecture; verify its signature using the [signed CLI guide](https://github.com/pilot-protocol/cosift-install/blob/main/docs/CLI-INSTALL.md). Put the binary on your PATH before running the installer so it can connect the CLI. If you installed the binary later, rerun the installer with `--cli` to save the shared session. After that handoff, the CLI discovers the private `cosift/community-session.json` under +(the `-worker` flag below needs a release newer than v0.4.2) for your OS and architecture; verify its signature using the [signed CLI guide](https://github.com/pilot-protocol/cosift-install/blob/main/docs/CLI-INSTALL.md). Put the binary on your PATH before running the installer so it can connect the CLI. If you installed the binary later, rerun the installer with `--cli` to save the shared session. After that handoff, the CLI discovers the private `cosift/community-session.json` under `$XDG_CONFIG_HOME`, or `~/.config` when unset. An explicit `-session-file` can select another saved session. Keep session files private (mode `0600`). @@ -89,6 +89,21 @@ https://go.dev/doc/modules/ https://www.rust-lang.org/learn ``` +To fetch pages that sites block for Cosift's own crawler, run the worker (it needs a release newer than v0.4.2): + +```sh +cosift contribute -server https://cosift.pilotprotocol.network -worker -max-pages 50 -duration 20m +``` + +It leases pages from the network, fetches them from your machine as a browser would, +and submits what it extracted; it makes requests to third-party sites from your +network. `-max-pages` (default 100), `-duration` (default 30m) and `-concurrency` +(default 4, 1 to 32) bound the run, and it prints one JSON line per event. It needs +your bearer token (`COSIFT_TOKEN`, or a saved session that holds one; a cookie-only +session is refused) and an account that has joined the contributor network and +accepted the current terms. A verified page whose content is new earns 2 credits, up to a daily +cap. See [Fetch work](COMMUNITY.md#fetch-work). + Submit useful public pages you are authorized to share. URL and content checks screen adult material, harmful content, malware/phishing, spam and low-quality filler; useful educational, medical and defensive-security material is allowed. @@ -102,8 +117,9 @@ metadata review does not guarantee moderation of images or video. ## Index and embed locally The CLI can fetch, extract, chunk and embed a page locally, retain its local index, -then submit its text, metadata and vectors. Configure a local embedding service -matching the destination model and dimensions. Production currently uses +then submit its text, metadata and vectors. Pages are fetched with browser request +headers through a public-only dialer, and every redirect hop is checked. Configure a +local embedding service matching the destination model and dimensions. Production currently uses `nomic-embed-text` with 768 dimensions; confirm compatibility before a large run. Example `local.json` for an OpenAI-compatible local embedding endpoint: From 70747fdcf9fc7b62eb8d4fa938c29299fbb43cf6 Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 11:14:19 +0300 Subject: [PATCH 118/121] docs: the cosift skill lists the contribute worker --- skills/cosift/SKILL.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/skills/cosift/SKILL.md b/skills/cosift/SKILL.md index b2bb961..6eb22ae 100644 --- a/skills/cosift/SKILL.md +++ b/skills/cosift/SKILL.md @@ -46,7 +46,7 @@ report that state and respect retry guidance; do not spin on repeated calls. ## CLI and contributions -Use stable Cosift v0.2.7 or newer. The CLI discovers the installer's private +Use stable Cosift v0.2.7 or newer (worker mode below needs a release newer than v0.4.2). The CLI discovers the installer's private session under `$XDG_CONFIG_HOME/cosift/community-session.json`, defaulting to `~/.config/cosift/community-session.json`. A missing or expired login needs setup; do not change identities to get around limits. @@ -68,6 +68,18 @@ per request and 1,000 new URLs per account per rolling 24 hours. Flags precede positional URLs. Submission acceptance means queued for checking; only verified indexing can earn credits. Check status in the web app. +To fetch pages that sites block for Cosift's own crawler, the CLI has a worker mode: + +```sh +cosift contribute -server https://cosift.pilotprotocol.network -worker -max-pages 50 -duration 20m +``` + +It makes requests to third-party sites from the user's network, so run it only when +the user asks. It needs the user's bearer token (`COSIFT_TOKEN` or a saved session that +holds one) and an account that has joined the contributor network and accepted the +current terms; `-max-pages`, `-duration` and `-concurrency` bound the run, and it +prints one JSON line per event. Verified new pages earn credits up to a daily cap. + For an explicitly requested local-indexing workflow, configure a compatible local embedding service and use: @@ -75,8 +87,8 @@ embedding service and use: cosift -config local.json contribute -server https://cosift.pilotprotocol.network -index-locally https://go.dev/doc/modules/ ``` -This fetches public content and creates a local index, then uploads text, metadata -and vectors. Match the destination model/dimensions; production currently uses +This fetches public content as a browser would and creates a local index, then uploads +text, metadata and vectors. Match the destination model/dimensions; production currently uses `nomic-embed-text`, 768 dimensions. The server checks source content, safety, quality and all vectors. Uncertain or unsupported content remains unverified, and text/metadata checks do not guarantee image or video moderation. Do not From 83464fcdc1e5f859722619281288a3c2b5b9c48c Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 11:14:25 +0300 Subject: [PATCH 119/121] docs: README lists the contribute worker and cosift work in the CLI reference --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 096d084..984d813 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,12 @@ cosift contents [-server URL] [-file PATH] [-text] [-json] hit /contents — single GET or batch POST cosift admin [-server URL] [-token TOKEN] [-json] admin-protected operator endpoints +cosift contribute -worker [-max-pages N] [-duration D] [-concurrency C] [-server URL] + fetch pages leased by the network and submit them + (bearer token required; see docs/COMMUNITY.md) +cosift work purge --url URL | void --worker ID [-y] [-server URL] [-token TOKEN] [-json] + remove a forged page, or one worker's pending + attestations; a dry run without -y cosift stats [-backend sqlite|pebble] doc / term counts + data dir (per-backend) cosift crawl-status [-hosts N] [-errors N] [-target N] live operator snapshot of an ongoing crawl: counts, frontier From f7755ffa8f8ca1590180a3daf33207b87f4a2b5b Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 11:14:37 +0300 Subject: [PATCH 120/121] docs: community.env.example names the work scope and the llms.txt page --- deploy/community.env.example | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/deploy/community.env.example b/deploy/community.env.example index 7448052..5a54d39 100644 --- a/deploy/community.env.example +++ b/deploy/community.env.example @@ -1,6 +1,7 @@ # Copy to the private community service environment file; fill in secrets there. # Empty Stripe values keep purchases disabled. Never commit real credentials. -# The community-broker principal's csk_ key. Required to deliver and moderate submissions. +# The community-broker principal's csk_ key. Required to deliver and moderate submissions; +# its principal also needs the work:broker scope when COSIFT_COMMUNITY_WORK=1. COSIFT_COMMUNITY_BROKER_KEY= # Loopback engine /v1 listener; default http://127.0.0.1:7779. COSIFT_COMMUNITY_ENGINE_URL= @@ -43,7 +44,7 @@ COSIFT_MCP_AUDIENCE= # An operator-provisioned ADC file readable only by the service account. # GOOGLE_APPLICATION_CREDENTIALS=/etc/cosift/google-credentials.json -# Public article pages: 1 turns /wiki, the sitemaps and robots.txt on. +# Public article pages: 1 turns /wiki, the sitemaps, robots.txt and llms.txt on. COSIFT_WIKI_PUBLIC= # Loopback engine /v1 listener; default http://127.0.0.1:7779. COSIFT_WIKI_ENGINE_URL= From ad3c34232ba4d2e7d9344bbef8e1225b5a1278ed Mon Sep 17 00:00:00 2001 From: Andrei Marinescu Date: Fri, 16 Oct 2026 11:14:56 +0300 Subject: [PATCH 121/121] docs: SHARED-ACCOUNTS.md notes the work alerts need document creation --- docs/SHARED-ACCOUNTS.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/SHARED-ACCOUNTS.md b/docs/SHARED-ACCOUNTS.md index cd138ed..b399ac5 100644 --- a/docs/SHARED-ACCOUNTS.md +++ b/docs/SHARED-ACCOUNTS.md @@ -90,8 +90,9 @@ preserved. No new ranking change is included. including topic tools. Credits do not bypass that cap or buy an article. Andrei's current MCP uses `NullArticleStore` with articles disabled. This work -connects article lookup/demand; it does **not** implement article generation, -Gemini research, a distributed contributor network, or rewards for article views. +connects article lookup/demand and, with `COSIFT_COMMUNITY_WORK=1`, brokers fetch work +for joined contributors; it does **not** implement article generation, Gemini +research, or rewards for article views. Lookups can return no coverage while regular source-page search remains usable. No local agent history is collected or uploaded by this integration. @@ -139,6 +140,14 @@ The gateway runtime identity requires: [`deploy/community-account-role.yaml`](../deploy/community-account-role.yaml); it grants reads/query and update, without document creation/deletion. Apply the intended project/database scope when binding it to the runtime identity. + With `COSIFT_COMMUNITY_WORK=1` the app also adds to `counters.fetch_*` on existing + contributor documents (an update) and creates documents in the `alerts` collection + when a moderation ceiling is reached. The retained role grants no creation, so those + alerts are not written: each attempt is logged as `work_alert_failed` and retried every + minute. Firestore IAM cannot grant creation for one collection. Enabling the alerts + means adding `datastore.entities.create` for the whole database, which would also let + this identity create account and token documents; weigh that against the role's + no-creation boundary, or leave the alerts off. - Secret accessor on **`cosift-token-pepper` only**, including the active version IDs carried by tokens. Never grant this application the OTP or mail secrets. - Cloud Run invoker on the chosen auth/MCP services when private. For an