Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ project_stale_days = 180
size_threshold = 1200
report_unresolved_links = false
# attachment_extensions defaults to a built-in list when omitted.
# exclude_from_graph defaults to empty (exclude nothing).
# exclude_from_graph = ["Vault Daily Digest", "Ingest Log", "Action Review", "My Open Actions", "Open Actions*"]
```

## Top-level keys
Expand Down Expand Up @@ -134,6 +136,7 @@ Obsidian).
| `size_threshold` | int | `1200` | Estimated token count (`len(body)/4`) above which a note is checked for multiple sections (oversized candidate). |
| `report_unresolved_links` | bool | `false` | List each unresolved wiki-link (a link to a note that does not exist) as a `dangling_link` finding. Obsidian treats these as expected future notes, so they are counted but not listed by default. `hebb audit --unresolved` forces listing for one run. |
| `attachment_extensions` | list of string | built-in list | File extensions (no leading dot) treated as attachment links and excluded from dangling checks, since hebb does not index non-note files. Empty uses the built-in default (`png`, `jpg`, `pdf`, `pptx`, `canvas`, `excalidraw`, ...). Setting it replaces the default rather than extending it. |
| `exclude_from_graph` | list of string | empty | Glob patterns matched against a note's title, basename without `.md`, and vault-relative path (any match excludes the note). A matched note is removed from the link graph before computing connected components, k-core coreness, orphans, leaves, and islands, so machine-generated scaffolding that links to hundreds of notes does not dominate those metrics. Content detectors (`dangling_link`, `ambiguous_link`, `para_drift`, `oversized`) are unaffected and still run over all notes. Patterns use `path.Match` semantics (shell-style globs over the `/`-separated vault path; `*` does not cross `/`). A malformed pattern fails the run with a clear error rather than being silently ignored. Default: empty (exclude nothing). The `--exclude-from-graph` flag on `hebb health` overrides this list for a single run and is useful for ad-hoc experiments. Example: `exclude_from_graph = ["Vault Daily Digest", "Ingest Log", "Action Review", "My Open Actions", "Open Actions*"]` |

Folder links (a target ending in `/`, or one naming a real directory) are never
treated as broken note links.
Expand Down
22 changes: 21 additions & 1 deletion METABOLISM.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,26 @@ coreness ranking actually puts your known durable reference notes at high corene
mis-ranks them as periphery, the structural axis is wrong (likely a Phase 0 resolution
bug) and no later access logging saves it.

**Validation result (2026-06-16, OneVault, 1067 notes): the gate FAILED as framed.**
k-core coreness does not track durable knowledge on a meeting-and-action-heavy work
vault. The maximum core was dominated by machine-generated hubs (the daily digest at
degree 297, the ingest log, the action-review and open-actions registers) plus the
recurring dated sync/meeting stream, with person notes mixed in. The human
maps-of-content (project indexes, dashboards) sat mid-core (k=8 of 13), only 1 of ~25
reaching the top two core levels. Coreness tracked raw degree closely (19/30 top-set
overlap), so it is essentially "centrality in the operational mesh".

An `exclude_from_graph` experiment (recompute coreness with scaffolding removed) showed
the effect is real but needs aggressive curation: excluding the 9 automation hubs alone
just promoted person notes (MOCs still 1/16); only after also excluding the recurring
sync/meeting notes (about 10% of the vault) did the MOCs reach the top tier (8/16).

Conclusion: coreness is a structural-centrality descriptor, not a durability axis, on a
work vault. `exclude_from_graph` is worth shipping to keep the dashboard's graph metrics
(orphans, islands, coreness) meaningful by stripping machine scaffolding, but Phase 5
must NOT gate protect/forget on coreness. Durability must come from the Phase 3
two-strength (access + stability) signal, as this plan already sequences.

---

## Phase 3 — Access log as a silent observer (~20 lines, no scorer)
Expand Down Expand Up @@ -352,7 +372,7 @@ spent ~20 lines, not a scorer plus a dashboard plus two jobs, finding out.
- [x] Phase 1: `core/health.go` + `hebb health` CLI: dangling-link, PARA-drift, oversized detectors
- [ ] Phase 1: dedup precision test (throwaway script, top-30 eyeball) before building dedup
- [x] Phase 2: orphans / components / k-core in `core/health.go`; worklist-first panel on `hebb serve`
- [ ] Phase 2: validate coreness puts known-durable notes at high coreness
- [x] Phase 2: validate coreness puts known-durable notes at high coreness — FAILED (2026-06-16): coreness tracks operational centrality, not durability, on a work vault (see "Validation result" in Phase 2). `exclude_from_graph` helps only with aggressive curation; durability deferred to Phase 3.
- [ ] Phase 3: `access_log` table + writes in the three MCP handlers (post-result), behind a config flag
- [ ] Phase 3: capture `return_count` and `followup_count` separately
- [ ] EXP: run the vault for two weeks; hand-label 30 durable + 30 transitory
Expand Down
22 changes: 22 additions & 0 deletions cli/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"sort"
"strings"

"github.com/cizer/hebb/core"
"github.com/spf13/cobra"
Expand All @@ -13,6 +14,7 @@ import (
func healthCmd() *cobra.Command {
var asJSON bool
var unresolved bool
var excludeFromGraph string
c := &cobra.Command{
Use: "audit",
Aliases: []string{"health"},
Expand Down Expand Up @@ -57,6 +59,20 @@ func healthCmd() *cobra.Command {
// default (report_unresolved_links, off unless set).
reportUnresolved := unresolved || cfg.Health.ReportUnresolvedLinks

// Effective exclude-from-graph list: the --exclude-from-graph flag
// (comma-separated glob patterns) overrides the config list for this
// run. When the flag is empty, the config list is used unchanged.
if excludeFromGraph != "" {
parts := strings.Split(excludeFromGraph, ",")
trimmed := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
trimmed = append(trimmed, s)
}
}
cfg.Health.ExcludeFromGraph = trimmed
}

result, err := core.RunHealthFull(cfg, db, reportUnresolved)
if err != nil {
return fmt.Errorf("health check failed: %w", err)
Expand Down Expand Up @@ -90,6 +106,12 @@ func healthCmd() *cobra.Command {
}
c.Flags().BoolVar(&asJSON, "json", false, "emit findings as a JSON array (for the Phase 2 dashboard)")
c.Flags().BoolVar(&unresolved, "unresolved", false, "list unresolved wiki-links (links to non-existent notes), suppressed by default")
c.Flags().StringVar(&excludeFromGraph, "exclude-from-graph", "",
"comma-separated glob patterns to drop from the graph for this run (overrides exclude_from_graph in config.toml). "+
"Patterns are matched against a note's title, basename without .md, and vault-relative path. "+
"Excluded notes are removed from graph metrics (coreness, components, orphans, islands) but content "+
"detectors (dangling_link, oversized, ...) still run over them. "+
"Example: --exclude-from-graph=\"Vault Daily Digest,Ingest Log,Open Actions*\"")
return c
}

Expand Down
83 changes: 83 additions & 0 deletions cli/health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,86 @@ func TestHealthCommandStructuralSummaryLine(t *testing.T) {
}
}
}

// buildExcludeVaultCLI builds a vault with a high-degree hub note ("Vault Daily
// Digest") and ordinary notes, returning the vault path and the name of the hub.
func buildExcludeVaultCLI(t *testing.T) string {
t.Helper()
vault := t.TempDir()
write := func(rel, content string) {
t.Helper()
p := filepath.Join(vault, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

if err := core.DefaultVaultConfig("test").Save(vault); err != nil {
t.Fatal(err)
}

write("Notes/A.md", "# A\n\n[[B]] [[C]]\n")
write("Notes/B.md", "# B\n\n[[A]]\n")
write("Notes/C.md", "# C\n\n[[A]]\n")
// Hub links to every other note.
write("Daily/Digest.md", "# Vault Daily Digest\n\n[[A]] [[B]] [[C]]\n")

cfg, err := core.ResolveVault(vault, "")
if err != nil {
t.Fatal(err)
}
db, err := core.OpenDB(cfg.DBPath)
if err != nil {
t.Fatal(err)
}
if _, err := core.FullReindex(cfg, db); err != nil {
db.Close()
t.Fatal(err)
}
db.Close()
return vault
}

// TestHealthCommandExcludeFromGraphFlag verifies that passing
// --exclude-from-graph="Vault Daily Digest" drops the hub note from the graph
// stats (node count falls by 1, the hub does not appear in text output's node
// count comparison).
func TestHealthCommandExcludeFromGraphFlag(t *testing.T) {
vault := buildExcludeVaultCLI(t)

// Without the flag: 4 notes in the graph.
outAll, err := runHealth(t, vault)
if err != nil {
t.Fatalf("hebb health (baseline): %v\n%s", err, outAll)
}
if !strings.Contains(outAll, "4 notes") {
t.Errorf("baseline graph summary should report 4 notes:\n%s", outAll)
}

// With the flag: 3 notes (Digest excluded).
outExcl, err := runHealth(t, vault, "--exclude-from-graph=Vault Daily Digest")
if err != nil {
t.Fatalf("hebb health --exclude-from-graph: %v\n%s", err, outExcl)
}
if !strings.Contains(outExcl, "3 notes") {
t.Errorf("excluded graph summary should report 3 notes:\n%s", outExcl)
}
}

// TestHealthCommandExcludeFromGraphFlagMultiple verifies that a
// comma-separated list of patterns in --exclude-from-graph excludes each one.
func TestHealthCommandExcludeFromGraphFlagMultiple(t *testing.T) {
vault := buildExcludeVaultCLI(t)

// Exclude both "Vault Daily Digest" and "A" (by title/basename).
outExcl, err := runHealth(t, vault, "--exclude-from-graph=Vault Daily Digest,A")
if err != nil {
t.Fatalf("hebb health --exclude-from-graph (multi): %v\n%s", err, outExcl)
}
if !strings.Contains(outExcl, "2 notes") {
t.Errorf("two excluded notes: graph summary should report 2 notes:\n%s", outExcl)
}
}
85 changes: 79 additions & 6 deletions core/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package core
import (
"database/sql"
"fmt"
"path"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -38,23 +39,88 @@ func (g *noteGraph) edgeCount() int {
return total / 2
}

// validateExcludePatterns checks every glob pattern for validity up front, so a
// malformed pattern (e.g. an unclosed "[") is reported rather than silently
// treated as a non-match. The whole point of the feature is graph-metric
// fidelity, so silently computing the wrong graph from a typo would invalidate
// the result. path.Match reports a bad pattern via ErrBadPattern regardless of
// the candidate string, so an empty candidate is enough to validate. An empty
// patterns slice validates trivially.
func validateExcludePatterns(patterns []string) error {
for _, pat := range patterns {
if _, err := path.Match(pat, ""); err != nil {
return fmt.Errorf("invalid exclude_from_graph pattern %q: %w", pat, err)
}
}
return nil
}

// matchesExcludePatterns reports whether a note should be excluded from the
// graph. A note is excluded when any of the supplied glob patterns matches any
// of the three candidates: title, basename-without-.md, or vault-relative path.
// Matching uses path.Match semantics (shell-style globs over the '/'-separated
// vault path, OS-independent: "*" does not cross "/"). Patterns are assumed
// pre-validated by validateExcludePatterns, so a match error here cannot occur.
// An empty patterns slice always returns false (exclude nothing).
func matchesExcludePatterns(patterns []string, title, notePath string) bool {
if len(patterns) == 0 {
return false
}
base := strings.TrimSuffix(path.Base(notePath), ".md")
for _, pat := range patterns {
if ok, _ := path.Match(pat, title); ok {
return true
}
if ok, _ := path.Match(pat, base); ok {
return true
}
if ok, _ := path.Match(pat, notePath); ok {
return true
}
}
return false
}

// buildGraph reads the notes and resolved links tables and constructs the
// undirected note graph. It is called by RunHealthFull (once per invocation,
// shared across all graph-based detectors) and by GraphHealth (for stats-only
// callers such as tests and future tooling).
func buildGraph(db *sql.DB) (*noteGraph, error) {
// Load all note paths, ordered for determinism.
rows, err := db.Query("SELECT path FROM notes ORDER BY path")
return buildGraphExcluding(db, nil)
}

// buildGraphExcluding is the underlying implementation for buildGraph. It
// accepts an optional slice of glob patterns (see matchesExcludePatterns); when
// non-empty, any note whose title, basename-without-.md, or vault-relative path
// matches ANY pattern is excluded from the graph entirely: it becomes neither a
// node nor the endpoint of any edge. Pass nil (or an empty slice) to build the
// full graph without exclusions.
func buildGraphExcluding(db *sql.DB, excludePatterns []string) (*noteGraph, error) {
// Validate the patterns before touching the DB: a malformed glob must fail
// the run with a clear message, not silently exclude nothing and report
// metrics over the unfiltered graph.
if err := validateExcludePatterns(excludePatterns); err != nil {
return nil, err
}
// Load all note paths and titles, ordered for determinism.
rows, err := db.Query("SELECT path, title FROM notes ORDER BY path")
if err != nil {
return nil, fmt.Errorf("graph: load notes: %w", err)
}
var nodes []string
// excluded is the set of paths that are filtered out by excludePatterns. It
// is used when loading edges to drop any edge incident to an excluded node.
excluded := make(map[string]bool)
for rows.Next() {
var p string
if err := rows.Scan(&p); err != nil {
var p, title string
if err := rows.Scan(&p, &title); err != nil {
rows.Close()
return nil, fmt.Errorf("graph: scan note path: %w", err)
}
if matchesExcludePatterns(excludePatterns, title, p) {
excluded[p] = true
continue
}
nodes = append(nodes, p)
}
rows.Close()
Expand Down Expand Up @@ -90,6 +156,10 @@ func buildGraph(db *sql.DB) (*noteGraph, error) {
lrows.Close()
return nil, fmt.Errorf("graph: scan link: %w", err)
}
// Drop any edge incident to an excluded note.
if excluded[src] || excluded[tgt] {
continue
}
si, sok := nodeIdx[src]
ti, tok := nodeIdx[tgt]
if !sok || !tok {
Expand Down Expand Up @@ -152,13 +222,16 @@ type GraphStats struct {
// It returns the full GraphStats summary. GraphHealth is used directly by the
// web layer (/api/health calls RunHealthFull, which builds its own graph
// internally) and by graph-stats tests. RunHealthFull does not call GraphHealth;
// it calls buildGraph itself and then calls computeComponents and
// it calls buildGraphExcluding itself and then calls computeComponents and
// computeCoreness directly so it can reuse the same graph for detectOrphansAndLeaves
// and detectIslands without a second DB round-trip. Consolidating the two paths
// would require changing GraphHealth's signature to accept a pre-built graph;
// left separate to keep the public API stable.
//
// Any notes matched by cfg.Health.GetExcludeFromGraph() are removed from the
// graph before metrics are computed (see matchesExcludePatterns).
func GraphHealth(cfg Config, db *sql.DB) (GraphStats, error) {
g, err := buildGraph(db)
g, err := buildGraphExcluding(db, cfg.Health.GetExcludeFromGraph())
if err != nil {
return GraphStats{}, err
}
Expand Down
Loading