sync before transfer
2
Makefile
|
|
@ -65,7 +65,7 @@ build:
|
|||
cabal run site -- build
|
||||
pagefind --site _site
|
||||
@if [ -d .venv ]; then \
|
||||
uv run python tools/embed.py || echo "Warning: embedding failed — data/similar-links.json not updated (build continues)"; \
|
||||
HF_HUB_DISABLE_IMPLICIT_TOKEN=1 uv run python tools/embed.py || echo "Warning: embedding failed — data/similar-links.json not updated (build continues)"; \
|
||||
else \
|
||||
echo "Embedding skipped: run 'uv sync' to enable similar-links (build continues)"; \
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -88,7 +88,8 @@ sectionOrder = nub . map neSection
|
|||
-- ranking next-to-the-top.
|
||||
statusRanks :: [(String, Int)]
|
||||
statusRanks =
|
||||
[ ("in-review", 1)
|
||||
[ ("accepted", 0)
|
||||
, ("in-review", 1)
|
||||
, ("revising", 2)
|
||||
, ("drafting", 3)
|
||||
, ("building", 4)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ module Patterns
|
|||
, photographyPattern
|
||||
, allPhotoEntries
|
||||
, standalonePagesPattern
|
||||
, pageCollectionPattern
|
||||
-- * Aggregated patterns
|
||||
, allWritings -- essays + blog + poetry + fiction
|
||||
, allContent -- everything that backlinks should index
|
||||
|
|
@ -32,11 +33,12 @@ import Hakyll
|
|||
-- Per-section
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | All published essays — flat files and directory-based (with co-located
|
||||
-- assets). Drafts under @content/drafts/essays/**@ are intentionally NOT
|
||||
-- included; 'Site.rules' unions them in conditionally when @SITE_ENV=dev@.
|
||||
-- | All published essays — flat files, directory-based essays, and entries
|
||||
-- inside one-level collection directories.
|
||||
essayPattern :: Pattern
|
||||
essayPattern = "content/essays/*.md" .||. "content/essays/*/index.md"
|
||||
essayPattern =
|
||||
"content/essays/*.md"
|
||||
.||. "content/essays/*/*.md"
|
||||
|
||||
-- | In-progress essay drafts. Matches the flat and directory forms under
|
||||
-- @content/drafts/essays/@. Only 'Site.rules' consumes this, gated on
|
||||
|
|
@ -48,10 +50,12 @@ draftEssayPattern =
|
|||
"content/drafts/essays/*.md"
|
||||
.||. "content/drafts/essays/*/index.md"
|
||||
|
||||
-- | All blog posts. Currently flat-only; co-located blog assets would
|
||||
-- require a directory variant analogous to 'essayPattern'.
|
||||
-- | All blog posts: flat posts plus entries inside collection directories.
|
||||
-- Collection index pages are landing pages and compile separately.
|
||||
blogPattern :: Pattern
|
||||
blogPattern = "content/blog/*.md"
|
||||
blogPattern =
|
||||
"content/blog/*.md"
|
||||
.||. ("content/blog/*/*.md" .&&. complement "content/blog/*/index.md")
|
||||
|
||||
-- | All poetry: flat poems plus collection poems, excluding collection
|
||||
-- index pages (which are landing pages, not poems).
|
||||
|
|
@ -60,9 +64,12 @@ poetryPattern =
|
|||
"content/poetry/*.md"
|
||||
.||. ("content/poetry/*/*.md" .&&. complement "content/poetry/*/index.md")
|
||||
|
||||
-- | All fiction. Currently flat-only.
|
||||
-- | All fiction: flat stories plus entries inside collection directories.
|
||||
-- Collection index pages are landing pages and compile separately.
|
||||
fictionPattern :: Pattern
|
||||
fictionPattern = "content/fiction/*.md"
|
||||
fictionPattern =
|
||||
"content/fiction/*.md"
|
||||
.||. ("content/fiction/*/*.md" .&&. complement "content/fiction/*/index.md")
|
||||
|
||||
-- | Music compositions (landing pages live at @content/music/<slug>/index.md@).
|
||||
musicPattern :: Pattern
|
||||
|
|
@ -106,12 +113,34 @@ allPhotoEntries =
|
|||
photographyPattern
|
||||
.||. ("content/photography/*/*.md" .&&. complement "content/photography/*/index.md")
|
||||
|
||||
-- | Top-level standalone pages (about, colophon, current, gpg, …) and
|
||||
-- the curated routing pages under @content/cv/@ (which render with the
|
||||
-- same @templates/page.html@ pipeline and need the same backlink and
|
||||
-- content-indexing treatment).
|
||||
-- | Page collection entries live one directory below @content/@. Known
|
||||
-- section directories are excluded so their files retain specialized
|
||||
-- compilers and routes.
|
||||
pageCollectionPattern :: Pattern
|
||||
pageCollectionPattern =
|
||||
"content/*/*.md" .&&. complement reservedSectionPages
|
||||
where
|
||||
reservedSectionPages =
|
||||
"content/blog/*.md"
|
||||
.||. "content/cv/*.md"
|
||||
.||. "content/drafts/*.md"
|
||||
.||. "content/essays/*.md"
|
||||
.||. "content/fiction/*.md"
|
||||
.||. "content/me/*.md"
|
||||
.||. "content/memento-mori/*.md"
|
||||
.||. "content/music/*.md"
|
||||
.||. "content/photography/*.md"
|
||||
.||. "content/poetry/*.md"
|
||||
.||. "content/scripts/*.md"
|
||||
.||. "content/tag-meta/*.md"
|
||||
|
||||
-- | Top-level standalone pages, curated CV routing pages, and generic page
|
||||
-- collections.
|
||||
standalonePagesPattern :: Pattern
|
||||
standalonePagesPattern = "content/*.md" .||. "content/cv/*.md"
|
||||
standalonePagesPattern =
|
||||
"content/*.md"
|
||||
.||. "content/cv/*.md"
|
||||
.||. pageCollectionPattern
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Aggregations
|
||||
|
|
|
|||
|
|
@ -365,6 +365,16 @@ rules = do
|
|||
>>= loadAndApplyTemplate "templates/default.html" pageCtx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- Generic page collections
|
||||
-- (content/<collection>/<slug>.md → <collection>/<slug>.html).
|
||||
match P.pageCollectionPattern $ do
|
||||
route $ stripPrefixRoute "content/"
|
||||
`composeRoutes` setExtension "html"
|
||||
compile $ pageCompiler
|
||||
>>= loadAndApplyTemplate "templates/page.html" pageCtx
|
||||
>>= loadAndApplyTemplate "templates/default.html" pageCtx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- CV routing pages (content/cv/*.md → /cv/<slug>/).
|
||||
-- These are narrative overlays pointing into the library; they render
|
||||
|
|
@ -391,20 +401,23 @@ rules = do
|
|||
-- ---------------------------------------------------------------------------
|
||||
match allEssays $ do
|
||||
route $ customRoute $ \ident ->
|
||||
let fp = toFilePath ident
|
||||
fname = takeFileName fp
|
||||
isIndex = fname == "index.md"
|
||||
isDraft = "content/drafts/essays/" `isPrefixOf` fp
|
||||
let fp = toFilePath ident
|
||||
fname = takeFileName fp
|
||||
isIndex = fname == "index.md"
|
||||
isDraft = "content/drafts/essays/" `isPrefixOf` fp
|
||||
stripContent = fromMaybe fp (stripPrefix "content/" fp)
|
||||
isNested = takeDirectory fp /= "content/essays"
|
||||
in case (isDraft, isIndex) of
|
||||
-- content/drafts/essays/slug/index.md → drafts/essays/slug/index.html
|
||||
(True, True) -> replaceExtension stripContent "html"
|
||||
-- content/drafts/essays/foo.md → drafts/essays/foo.html
|
||||
-- content/drafts/essays/foo.md → drafts/essays/foo.html
|
||||
(True, False) -> "drafts/essays/" ++ replaceExtension fname "html"
|
||||
-- content/essays/slug/index.md → essays/slug/index.html
|
||||
(False, True) -> replaceExtension stripContent "html"
|
||||
-- content/essays/foo.md → essays/foo.html
|
||||
-- Published directory essays and collection entries retain
|
||||
-- their directory path below content/.
|
||||
(False, _) | isNested -> replaceExtension stripContent "html"
|
||||
-- content/essays/foo.md → essays/foo.html
|
||||
(False, False) -> "essays/" ++ replaceExtension fname "html"
|
||||
(False, True) -> replaceExtension stripContent "html"
|
||||
compile $ essayCompiler
|
||||
>>= saveSnapshot "content"
|
||||
>>= loadAndApplyTemplate "templates/essay.html" essayCtx
|
||||
|
|
@ -415,8 +428,7 @@ rules = do
|
|||
-- Build-time dimension sidecars are excluded; they're consumed by
|
||||
-- Filters/Images.hs at compile time, not shipped.
|
||||
match ("content/essays/**"
|
||||
.&&. complement "content/essays/*.md"
|
||||
.&&. complement "content/essays/*/index.md"
|
||||
.&&. complement P.essayPattern
|
||||
.&&. complement "content/essays/**/*.dims.yaml") $ do
|
||||
route $ stripPrefixRoute "content/"
|
||||
compile copyFileCompiler
|
||||
|
|
@ -432,7 +444,7 @@ rules = do
|
|||
-- ---------------------------------------------------------------------------
|
||||
-- Blog posts
|
||||
-- ---------------------------------------------------------------------------
|
||||
match "content/blog/*.md" $ do
|
||||
match P.blogPattern $ do
|
||||
route $ stripPrefixRoute "content/"
|
||||
`composeRoutes` setExtension "html"
|
||||
compile $ postCompiler
|
||||
|
|
@ -441,6 +453,15 @@ rules = do
|
|||
>>= loadAndApplyTemplate "templates/default.html" postCtx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- Blog collection index pages
|
||||
-- (content/blog/<collection>/index.md → blog/<collection>/index.html).
|
||||
match "content/blog/*/index.md" $ do
|
||||
route $ stripPrefixRoute "content/"
|
||||
`composeRoutes` setExtension "html"
|
||||
compile $ pageCompiler
|
||||
>>= loadAndApplyTemplate "templates/default.html" pageCtx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Poetry
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
@ -468,7 +489,7 @@ rules = do
|
|||
-- ---------------------------------------------------------------------------
|
||||
-- Fiction
|
||||
-- ---------------------------------------------------------------------------
|
||||
match "content/fiction/*.md" $ do
|
||||
match P.fictionPattern $ do
|
||||
route $ stripPrefixRoute "content/"
|
||||
`composeRoutes` setExtension "html"
|
||||
compile $ fictionCompiler
|
||||
|
|
@ -477,6 +498,15 @@ rules = do
|
|||
>>= loadAndApplyTemplate "templates/default.html" fictionCtx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- Fiction collection index pages
|
||||
-- (content/fiction/<collection>/index.md → fiction/<collection>/index.html).
|
||||
match "content/fiction/*/index.md" $ do
|
||||
route $ stripPrefixRoute "content/"
|
||||
`composeRoutes` setExtension "html"
|
||||
compile $ pageCompiler
|
||||
>>= loadAndApplyTemplate "templates/default.html" pageCtx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Music — catalog index
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -820,9 +820,9 @@ statsRules tags = do
|
|||
-- Load all content items
|
||||
-- ----------------------------------------------------------------
|
||||
essays <- loadAll (P.essayPattern .&&. hasNoVersion)
|
||||
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
|
||||
poems <- loadAll ("content/poetry/*.md" .&&. hasNoVersion)
|
||||
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
|
||||
posts <- loadAll (P.blogPattern .&&. hasNoVersion)
|
||||
poems <- loadAll (P.poetryPattern .&&. hasNoVersion)
|
||||
fiction <- loadAll (P.fictionPattern .&&. hasNoVersion)
|
||||
comps <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
|
|
@ -1020,9 +1020,9 @@ statsRules tags = do
|
|||
_ <- load (fromFilePath "data/build-stamp.txt") :: Compiler (Item String)
|
||||
|
||||
essays <- loadAll (P.essayPattern .&&. hasNoVersion)
|
||||
posts <- loadAll ("content/blog/*.md" .&&. hasNoVersion)
|
||||
poems <- loadAll ("content/poetry/*.md" .&&. hasNoVersion)
|
||||
fiction <- loadAll ("content/fiction/*.md" .&&. hasNoVersion)
|
||||
posts <- loadAll (P.blogPattern .&&. hasNoVersion)
|
||||
poems <- loadAll (P.poetryPattern .&&. hasNoVersion)
|
||||
fiction <- loadAll (P.fictionPattern .&&. hasNoVersion)
|
||||
comps <- loadAll ("content/music/*/index.md" .&&. hasNoVersion)
|
||||
|
||||
essayWCs <- mapM loadWC essays
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ active-repositories: hackage.haskell.org:merge
|
|||
constraints: any.Glob ==0.10.2,
|
||||
any.HUnit ==1.6.2.0,
|
||||
any.JuicyPixels ==3.3.9,
|
||||
any.OneTuple ==0.4.2.1,
|
||||
any.OneTuple ==0.4.3,
|
||||
any.Only ==0.1,
|
||||
any.QuickCheck ==2.15.0.1,
|
||||
any.StateVar ==1.2.2,
|
||||
any.aeson ==2.2.2.0,
|
||||
any.aeson-pretty ==0.8.10,
|
||||
any.aeson-pretty ==0.8.11,
|
||||
any.ansi-terminal ==1.1,
|
||||
any.ansi-terminal-types ==1.1,
|
||||
any.appar ==0.1.8,
|
||||
|
|
@ -18,17 +18,17 @@ constraints: any.Glob ==0.10.2,
|
|||
any.assoc ==1.1.1,
|
||||
any.async ==2.2.6,
|
||||
any.attoparsec ==0.14.4,
|
||||
any.attoparsec-aeson ==2.2.0.1,
|
||||
any.attoparsec-aeson ==2.2.2.0,
|
||||
any.auto-update ==0.1.6,
|
||||
any.base ==4.18.2.1,
|
||||
any.base-compat ==0.14.1,
|
||||
any.base-orphans ==0.9.3,
|
||||
any.base-orphans ==0.9.4,
|
||||
any.base16-bytestring ==1.0.2.0,
|
||||
any.base64-bytestring ==1.2.1.0,
|
||||
any.basement ==0.0.16,
|
||||
any.bifunctors ==5.6.3,
|
||||
any.binary ==0.8.9.1,
|
||||
any.bitvec ==1.1.5.0,
|
||||
any.bitvec ==1.1.6.0,
|
||||
any.blaze-builder ==0.4.4.1,
|
||||
any.blaze-html ==0.9.2.0,
|
||||
any.blaze-markup ==0.8.3.0,
|
||||
|
|
@ -41,23 +41,24 @@ constraints: any.Glob ==0.10.2,
|
|||
any.cborg ==0.2.10.0,
|
||||
any.cereal ==0.5.8.3,
|
||||
any.character-ps ==0.1,
|
||||
any.citeproc ==0.8.1.1,
|
||||
any.citeproc ==0.8.1.2,
|
||||
any.colour ==2.3.7,
|
||||
any.commonmark ==0.2.6.1,
|
||||
any.commonmark-extensions ==0.2.5.6,
|
||||
any.commonmark-pandoc ==0.2.2.3,
|
||||
any.commonmark-extensions ==0.2.6,
|
||||
any.commonmark-pandoc ==0.2.3,
|
||||
any.comonad ==5.0.10,
|
||||
any.conduit ==1.3.6.1,
|
||||
any.conduit-extra ==1.3.8,
|
||||
any.containers ==0.6.7,
|
||||
any.contravariant ==1.5.6,
|
||||
any.cookie ==0.5.0,
|
||||
any.crypton ==1.0.4,
|
||||
any.cryptohash-md5 ==0.11.101.0,
|
||||
any.crypton ==1.0.5,
|
||||
any.crypton-connection ==0.4.5,
|
||||
any.crypton-socks ==0.6.2,
|
||||
any.crypton-x509 ==1.7.7,
|
||||
any.crypton-x509-store ==1.6.12,
|
||||
any.crypton-x509-system ==1.6.7,
|
||||
any.crypton-x509-store ==1.6.14,
|
||||
any.crypton-x509-system ==1.6.8,
|
||||
any.crypton-x509-validation ==1.6.14,
|
||||
any.data-default ==0.7.1.3,
|
||||
any.data-default-class ==0.1.2.2,
|
||||
|
|
@ -71,10 +72,10 @@ constraints: any.Glob ==0.10.2,
|
|||
any.distributive ==0.6.3,
|
||||
any.djot ==0.1.2.4,
|
||||
any.dlist ==1.0,
|
||||
any.doclayout ==0.5.0.1,
|
||||
any.doclayout ==0.5.0.2,
|
||||
any.doctemplates ==0.11.0.1,
|
||||
any.easy-file ==0.2.5,
|
||||
any.emojis ==0.1.4.1,
|
||||
any.emojis ==0.1.5,
|
||||
any.exceptions ==0.10.7,
|
||||
any.fast-logger ==3.2.4,
|
||||
any.file-embed ==0.0.16.0,
|
||||
|
|
@ -99,8 +100,8 @@ constraints: any.Glob ==0.10.2,
|
|||
http-conduit +aeson,
|
||||
any.http-date ==0.0.11,
|
||||
any.http-types ==0.12.4,
|
||||
any.http2 ==5.1.2,
|
||||
any.indexed-traversable ==0.1.4,
|
||||
any.http2 ==5.1.4,
|
||||
any.indexed-traversable ==0.1.5,
|
||||
any.indexed-traversable-instances ==0.1.2.1,
|
||||
any.integer-conversion ==0.1.1,
|
||||
any.integer-gmp ==1.1,
|
||||
|
|
@ -117,20 +118,20 @@ constraints: any.Glob ==0.10.2,
|
|||
any.mtl ==2.3.1,
|
||||
any.network ==3.1.4.0,
|
||||
any.network-byte-order ==0.1.7,
|
||||
any.network-control ==0.1.3,
|
||||
any.network-control ==0.1.4,
|
||||
any.network-uri ==2.6.4.2,
|
||||
any.old-locale ==1.0.0.7,
|
||||
any.old-time ==1.1.0.5,
|
||||
any.old-time ==1.1.1.0,
|
||||
any.optparse-applicative ==0.18.1.0,
|
||||
any.ordered-containers ==0.2.4,
|
||||
any.os-string ==2.0.10,
|
||||
any.pandoc ==3.6,
|
||||
any.pandoc-types ==1.23.1,
|
||||
any.os-string ==2.0.11,
|
||||
any.pandoc ==3.6.1,
|
||||
any.pandoc-types ==1.23.1.2,
|
||||
any.parsec ==3.1.16.1,
|
||||
any.pem ==0.2.4,
|
||||
any.pretty ==1.1.3.6,
|
||||
any.pretty-show ==1.10,
|
||||
any.prettyprinter ==1.7.1,
|
||||
any.prettyprinter ==1.7.2,
|
||||
any.prettyprinter-ansi-terminal ==1.1.4,
|
||||
any.primitive ==0.9.1.0,
|
||||
any.process ==1.6.19.0,
|
||||
|
|
@ -139,7 +140,7 @@ constraints: any.Glob ==0.10.2,
|
|||
any.recv ==0.1.1,
|
||||
any.regex-base ==0.94.0.3,
|
||||
any.regex-tdfa ==1.3.2.5,
|
||||
any.resourcet ==1.2.6,
|
||||
any.resourcet ==1.3.0,
|
||||
any.rts ==1.0.2,
|
||||
any.safe ==0.3.21,
|
||||
any.safe-exceptions ==0.1.7.4,
|
||||
|
|
@ -148,23 +149,23 @@ constraints: any.Glob ==0.10.2,
|
|||
any.semigroupoids ==6.0.2,
|
||||
any.serialise ==0.2.6.1,
|
||||
any.simple-sendfile ==0.2.32,
|
||||
any.skylighting ==0.14.4,
|
||||
any.skylighting-core ==0.14.4,
|
||||
any.skylighting ==0.14.5,
|
||||
any.skylighting-core ==0.14.5,
|
||||
any.skylighting-format-ansi ==0.1,
|
||||
any.skylighting-format-blaze-html ==0.1.1.3,
|
||||
any.skylighting-format-blaze-html ==0.1.2,
|
||||
any.skylighting-format-context ==0.1.0.2,
|
||||
any.skylighting-format-latex ==0.1,
|
||||
any.split ==0.2.5,
|
||||
any.splitmix ==0.1.3,
|
||||
any.splitmix ==0.1.3.1,
|
||||
any.stm ==2.5.1.0,
|
||||
any.streaming-commons ==0.2.3.1,
|
||||
any.strict ==0.5.1,
|
||||
any.syb ==0.7.3,
|
||||
any.tagged ==0.8.9,
|
||||
any.syb ==0.7.4,
|
||||
any.tagged ==0.8.10,
|
||||
any.tagsoup ==0.14.8,
|
||||
any.template-haskell ==2.20.0.0,
|
||||
any.temporary ==1.3,
|
||||
any.texmath ==0.12.8.12,
|
||||
any.texmath ==0.12.8.13,
|
||||
any.text ==2.0.2,
|
||||
any.text-conversions ==0.3.1.1,
|
||||
any.text-icu ==0.8.0.5,
|
||||
|
|
@ -187,8 +188,8 @@ constraints: any.Glob ==0.10.2,
|
|||
any.typed-process ==0.2.13.0,
|
||||
any.typst ==0.6.1,
|
||||
any.typst-symbols ==0.1.7,
|
||||
any.unicode-collation ==0.1.3.6,
|
||||
any.unicode-data ==0.6.0,
|
||||
any.unicode-collation ==0.1.3.7,
|
||||
any.unicode-data ==0.7.0,
|
||||
any.unicode-transforms ==0.4.0.1,
|
||||
any.uniplate ==1.6.13,
|
||||
any.unix ==2.8.4.0,
|
||||
|
|
@ -196,7 +197,7 @@ constraints: any.Glob ==0.10.2,
|
|||
any.unix-time ==0.4.17,
|
||||
any.unliftio ==0.2.25.1,
|
||||
any.unliftio-core ==0.2.1.0,
|
||||
any.unordered-containers ==0.2.20.1,
|
||||
any.unordered-containers ==0.2.21,
|
||||
any.utf8-string ==1.0.2,
|
||||
any.uuid-types ==1.0.6.1,
|
||||
any.vault ==0.3.1.6,
|
||||
|
|
@ -204,11 +205,11 @@ constraints: any.Glob ==0.10.2,
|
|||
any.vector-algorithms ==0.9.1.0,
|
||||
any.vector-stream ==0.1.0.1,
|
||||
any.wai ==3.2.4,
|
||||
any.wai-app-static ==3.1.9,
|
||||
any.wai-app-static ==3.1.9.1,
|
||||
any.wai-extra ==3.1.18,
|
||||
any.wai-logger ==2.5.0,
|
||||
any.warp ==3.4.0,
|
||||
any.witherable ==0.4.2,
|
||||
any.witherable ==0.5,
|
||||
any.word8 ==0.1.3,
|
||||
any.xml ==1.3.14,
|
||||
any.xml-conduit ==1.9.1.4,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
% ── Foundational papers (cops-and-robbers) ──────────────────────────────────
|
||||
|
||||
@article{AignerFromme,
|
||||
author = {Aigner, M. and Fromme, M.},
|
||||
title = {A game of cops and robbers},
|
||||
journal = {Discrete Applied Mathematics},
|
||||
volume = {8},
|
||||
year = {1984},
|
||||
pages = {1--12},
|
||||
}
|
||||
|
||||
% ── Meyniel's conjecture: bounds and partial progress ───────────────────────
|
||||
|
||||
@article{LuPeng,
|
||||
author = {Lu, L. and Peng, X.},
|
||||
title = {On {Meyniel}'s conjecture of the cop number},
|
||||
journal = {Journal of Graph Theory},
|
||||
volume = {71},
|
||||
number = {2},
|
||||
year = {2012},
|
||||
pages = {192--205},
|
||||
}
|
||||
|
||||
@article{ScottSudakov,
|
||||
author = {Scott, A. and Sudakov, B.},
|
||||
title = {A bound for the cops and robbers problem},
|
||||
journal = {SIAM Journal on Discrete Mathematics},
|
||||
volume = {25},
|
||||
number = {3},
|
||||
year = {2011},
|
||||
pages = {1438--1442},
|
||||
}
|
||||
|
||||
@article{BoseEsperetHodorJoretMicekRambaud,
|
||||
author = {Bose, P. and Esperet, L. and Hodor, J. and Joret, G. and Micek, P. and Rambaud, C.},
|
||||
title = {Cops and robber in graphs with bounded vertex cover number},
|
||||
journal = {arXiv:2602.07435},
|
||||
year = {2026},
|
||||
}
|
||||
|
||||
@article{BradshawHosseiniMoharStacho,
|
||||
author = {Bradshaw, P. and Hosseini, S. A. and Mohar, B. and Stacho, L.},
|
||||
title = {On the cop number of graphs of high girth},
|
||||
journal = {Journal of Graph Theory},
|
||||
volume = {102},
|
||||
year = {2023},
|
||||
pages = {15--34},
|
||||
note = {arXiv:2005.10849},
|
||||
}
|
||||
|
||||
@misc{Clow,
|
||||
author = {Clow, A.},
|
||||
title = {Expanders satisfy the weak {Meyniel} conjecture},
|
||||
year = {2023},
|
||||
note = {Withdrawn preprint, arXiv:2311.13792},
|
||||
}
|
||||
|
||||
@article{HMG,
|
||||
author = {Hosseini, S. A. and Mohar, B. and {Gonzalez Hermosillo de la Maza}, S.},
|
||||
title = {{Meyniel}'s conjecture on graphs of bounded degree},
|
||||
journal = {Journal of Graph Theory},
|
||||
volume = {97},
|
||||
year = {2021},
|
||||
pages = {401--407},
|
||||
note = {arXiv:1912.06957},
|
||||
}
|
||||
|
||||
@article{PralatWormald,
|
||||
author = {Pra{\l}at, P. and Wormald, N.},
|
||||
title = {{Meyniel}'s conjecture holds for random graphs},
|
||||
journal = {Random Structures \& Algorithms},
|
||||
volume = {48},
|
||||
number = {2},
|
||||
year = {2016},
|
||||
pages = {396--421},
|
||||
note = {arXiv:1301.2841},
|
||||
}
|
||||
|
||||
% ── Expansion, isoperimetry, and product/replacement structures ────────────
|
||||
|
||||
@article{BollobasKunLeader,
|
||||
author = {Bollob\'as, B. and Kun, G. and Leader, I.},
|
||||
title = {Cops and robbers in a random graph},
|
||||
journal = {Journal of Combinatorial Theory, Series B},
|
||||
volume = {103},
|
||||
year = {2013},
|
||||
pages = {226--236},
|
||||
}
|
||||
|
||||
@article{BollobasLeader,
|
||||
author = {Bollob\'as, B. and Leader, I.},
|
||||
title = {An isoperimetric inequality on the discrete torus},
|
||||
journal = {SIAM Journal on Discrete Mathematics},
|
||||
volume = {3},
|
||||
year = {1990},
|
||||
pages = {32--37},
|
||||
}
|
||||
|
||||
@article{ReingoldVadhanWigderson,
|
||||
author = {Reingold, O. and Vadhan, S. and Wigderson, A.},
|
||||
title = {Entropy waves, the zig-zag graph product, and new constant-degree expanders},
|
||||
journal = {Annals of Mathematics},
|
||||
volume = {155},
|
||||
year = {2002},
|
||||
pages = {157--187},
|
||||
}
|
||||
|
||||
% ── Cop number on tori and products ─────────────────────────────────────────
|
||||
|
||||
@article{Lehner,
|
||||
author = {Lehner, F.},
|
||||
title = {On the cop number of toroidal graphs},
|
||||
journal = {Journal of Combinatorial Theory, Series B},
|
||||
volume = {151},
|
||||
year = {2021},
|
||||
pages = {250--262},
|
||||
}
|
||||
|
||||
@article{NeufeldNowakowski,
|
||||
author = {Neufeld, S. and Nowakowski, R.},
|
||||
title = {A game of cops and robbers played on products of graphs},
|
||||
journal = {Discrete Mathematics},
|
||||
volume = {186},
|
||||
year = {1998},
|
||||
pages = {253--268},
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
% ── Cops-and-robbers background ─────────────────────────────────────────────
|
||||
|
||||
@article{AignerFromme,
|
||||
author = {Aigner, M. and Fromme, M.},
|
||||
title = {A game of cops and robbers},
|
||||
journal = {Discrete Applied Mathematics},
|
||||
volume = {8},
|
||||
number = {1},
|
||||
year = {1984},
|
||||
pages = {1--12},
|
||||
}
|
||||
|
||||
@article{LuPeng,
|
||||
author = {Lu, L. and Peng, X.},
|
||||
title = {On {Meyniel}'s conjecture of the cop number},
|
||||
journal = {Journal of Graph Theory},
|
||||
volume = {71},
|
||||
number = {2},
|
||||
year = {2012},
|
||||
pages = {192--205},
|
||||
}
|
||||
|
||||
@article{ScottSudakov,
|
||||
author = {Scott, A. and Sudakov, B.},
|
||||
title = {A bound for the cops and robbers problem},
|
||||
journal = {SIAM Journal on Discrete Mathematics},
|
||||
volume = {25},
|
||||
number = {3},
|
||||
year = {2011},
|
||||
pages = {1438--1442},
|
||||
}
|
||||
|
||||
@article{PralatWormald,
|
||||
author = {Pra{\l}at, P. and Wormald, N.},
|
||||
title = {{Meyniel}'s conjecture holds for random graphs},
|
||||
journal = {Random Structures \& Algorithms},
|
||||
volume = {48},
|
||||
number = {2},
|
||||
year = {2016},
|
||||
pages = {396--421},
|
||||
}
|
||||
|
||||
@article{PralatWormaldRegular,
|
||||
author = {Pra{\l}at, P. and Wormald, N.},
|
||||
title = {{Meyniel}'s conjecture holds for random $d$-regular graphs},
|
||||
journal = {Random Structures \& Algorithms},
|
||||
volume = {55},
|
||||
number = {3},
|
||||
year = {2019},
|
||||
pages = {719--741},
|
||||
}
|
||||
|
||||
@misc{NeuwirthBranchCapture,
|
||||
author = {Neuwirth, Levi},
|
||||
title = {Branch-tube persistence and static coverage in tree-ball geometry},
|
||||
note = {Preprint},
|
||||
year = {2026},
|
||||
url = {https://levineuwirth.org/essays/branch-based-local-capture-in-tree-balls/index.html},
|
||||
}
|
||||
|
||||
% ── Random-regular / configuration-model background ────────────────────────
|
||||
|
||||
@incollection{Wormald,
|
||||
author = {Wormald, N. C.},
|
||||
title = {Models of random regular graphs},
|
||||
booktitle = {Surveys in Combinatorics, 1999},
|
||||
series = {London Mathematical Society Lecture Note Series},
|
||||
volume = {267},
|
||||
publisher = {Cambridge University Press},
|
||||
year = {1999},
|
||||
pages = {239--298},
|
||||
}
|
||||
|
||||
@article{Janson,
|
||||
author = {Janson, S.},
|
||||
title = {The probability that a random multigraph is simple},
|
||||
journal = {Combinatorics, Probability and Computing},
|
||||
volume = {18},
|
||||
year = {2009},
|
||||
pages = {205--225},
|
||||
}
|
||||
|
||||
% ── Domination in regular / random graphs ───────────────────────────────────
|
||||
|
||||
@book{CohenHonkalaLitsynLobstein,
|
||||
author = {Cohen, G. and Honkala, I. and Litsyn, S. and Lobstein, A.},
|
||||
title = {Covering Codes},
|
||||
series = {North-Holland Mathematical Library},
|
||||
volume = {54},
|
||||
publisher = {Elsevier},
|
||||
year = {1997},
|
||||
}
|
||||
|
||||
@article{Duckworth,
|
||||
author = {Duckworth, W.},
|
||||
title = {Randomized greedy algorithms for finding small $k$-dominating sets of regular graphs},
|
||||
journal = {Random Structures \& Algorithms},
|
||||
volume = {27},
|
||||
year = {2005},
|
||||
pages = {401--412},
|
||||
}
|
||||
|
||||
@article{DuckworthWormald,
|
||||
author = {Duckworth, W. and Wormald, N. C.},
|
||||
title = {On the independent domination number of random regular graphs},
|
||||
journal = {Combinatorics, Probability and Computing},
|
||||
volume = {15},
|
||||
year = {2006},
|
||||
pages = {513--522},
|
||||
}
|
||||
|
||||
@article{CutlerRadcliffe,
|
||||
author = {Cutler, J. and Radcliffe, A. J.},
|
||||
title = {Counting dominating sets and related structures in graphs},
|
||||
journal = {Discrete Mathematics},
|
||||
volume = {339},
|
||||
year = {2016},
|
||||
pages = {1593--1599},
|
||||
}
|
||||
|
||||
@article{GlebovLiebenauSzabo,
|
||||
author = {Glebov, R. and Liebenau, A. and Szab{\'o}, T.},
|
||||
title = {On the concentration of the domination number of the random graph},
|
||||
journal = {SIAM Journal on Discrete Mathematics},
|
||||
volume = {29},
|
||||
year = {2015},
|
||||
pages = {1186--1206},
|
||||
}
|
||||
|
||||
% ── Statistical-mechanics / message-passing antecedents ─────────────────────
|
||||
|
||||
@article{ZhaoHabibullaZhou,
|
||||
author = {Zhao, J.-H. and Habibulla, Y. and Zhou, H.-J.},
|
||||
title = {Statistical mechanics of the minimum dominating set problem},
|
||||
journal = {Journal of Statistical Physics},
|
||||
volume = {159},
|
||||
year = {2015},
|
||||
pages = {1154--1174},
|
||||
}
|
||||
|
||||
@misc{HabibullaQin,
|
||||
author = {Habibulla, Y. and Qin, S.-m.},
|
||||
title = {Two-distance minimal dominating set problem studied by statistical mechanics and simulated annealing},
|
||||
howpublished = {arXiv:1910.07933},
|
||||
year = {2019},
|
||||
}
|
||||
|
||||
% ── Variational-analysis background ─────────────────────────────────────────
|
||||
|
||||
@book{RockafellarWets,
|
||||
author = {Rockafellar, R. T. and Wets, R. J.-B.},
|
||||
title = {Variational Analysis},
|
||||
series = {Grundlehren der mathematischen Wissenschaften},
|
||||
volume = {317},
|
||||
publisher = {Springer},
|
||||
year = {1998},
|
||||
}
|
||||
|
|
@ -9,9 +9,11 @@
|
|||
# - title : display name
|
||||
# - section : free-form section key (research, engineering, …);
|
||||
# sections render in first-appearance order
|
||||
# - status : in-review | revising | drafting | building | early-stage |
|
||||
# paused. Free-form, but the listed values are the canonical
|
||||
# ladder (closest-to-shipping → furthest) and have CSS accents.
|
||||
# - status : accepted | in-review | revising | drafting | building |
|
||||
# early-stage | paused. Free-form, but the listed values are
|
||||
# the canonical ladder (closest-to-shipping → furthest) and
|
||||
# have CSS accents. "accepted" is for a paper/talk past review
|
||||
# and locked in for publication or presentation.
|
||||
# - updated : YYYY-MM-DD when this entry last moved (NOT page-stamp)
|
||||
# - link : optional URL (artifact, preprint, repo, etc.)
|
||||
# - note : optional one-sentence what's-happening-now line
|
||||
|
|
@ -29,22 +31,37 @@
|
|||
# Sorted newest-first at render time. Curate by hand — items don't
|
||||
# auto-prune. Move things off the list when they stop earning the slot.
|
||||
|
||||
last-updated: 2026-05-06
|
||||
last-updated: 2026-07-24
|
||||
|
||||
entries:
|
||||
- title: "Branch-Based Local Capture in Tree-Ball Geometry"
|
||||
- title: "MARS V: Zero-Knowledge Proofs for LLM Verification"
|
||||
section: research
|
||||
status: in-review
|
||||
updated: 2026-05-06
|
||||
link: /essays/branch-based-local-capture-in-tree-balls/
|
||||
note: "First mathematics preprint. Sharp positive and negative results for local team-chase in d-regular tree-balls. arXiv submission pending."
|
||||
status: building
|
||||
updated: 2026-07-10
|
||||
priority: 1
|
||||
link: https://caish.org/mars
|
||||
note: "MARS V fellowship at the Cambridge AI Safety Hub, mentored by James Petrie (Future of Life Institute). Zero-knowledge proofs for cryptographic verification of claims about LLM training, inference, and deployment — a mathematically dense, information-theoretic approach. July–October 2026; public write-up expected October 2026."
|
||||
|
||||
- title: "Order-Invariant ICD-10-CM Embedding (JAMA submission)"
|
||||
- title: "Branch-Tube Persistence and Static Coverage in Tree-Ball Geometry"
|
||||
section: research
|
||||
status: in-review
|
||||
updated: 2026-04-10
|
||||
updated: 2026-07-21
|
||||
link: /essays/branch-based-local-capture-in-tree-balls/
|
||||
note: "Mathematics preprint on path-tube persistence, exhaustive static coverage, conditional sampling thresholds, and information loss in d-regular tree-balls."
|
||||
|
||||
- title: "The Annealed Critical Window for Growing-Radius Domination in Random Regular Graphs"
|
||||
section: research
|
||||
status: in-review
|
||||
updated: 2026-07-24
|
||||
link: /essays/near-critical-growing-radius-domination.html
|
||||
note: "Mathematics preprint determining the bounded annealed critical window for growing-radius domination in random regular graphs — its universal scaling function and a scalar coupon-root asymptotic expansion — resolving what an earlier near-critical version left open."
|
||||
|
||||
- title: "Order-Invariant ICD-10-CM Embedding (JAMIA submission)"
|
||||
section: research
|
||||
status: in-review
|
||||
updated: 2026-07-10
|
||||
link: /essays/beyond-comorbidity-indices/
|
||||
note: "Under review at JAMA Network Open. Calculator deployed at levineuwirth.github.io/icd_embeddings."
|
||||
note: "Under review at JAMIA (Journal of the American Medical Informatics Association), after moving from JAMA Network Open. Calculator deployed at levineuwirth.github.io/icd_embeddings."
|
||||
|
||||
- title: "Semantic-embeddings citation work"
|
||||
section: research
|
||||
|
|
@ -69,15 +86,15 @@ entries:
|
|||
|
||||
- title: "Magic: The Gathering reinforcement learning"
|
||||
section: research
|
||||
status: early-stage
|
||||
updated: 2026-04-15
|
||||
note: "Early-stage RL on Brown's OSCAR HPC cluster; expected late 2026."
|
||||
status: building
|
||||
updated: 2026-07-10
|
||||
note: "Reinforcement-learning agent under active development on Brown's OSCAR HPC cluster; expected late 2026."
|
||||
|
||||
- title: "CHASE 2026 conference submission"
|
||||
section: research
|
||||
status: in-review
|
||||
updated: 2026-04-05
|
||||
note: "IEEE/ACM Conference on Connected Health (CHASE), August 2026, in review."
|
||||
status: accepted
|
||||
updated: 2026-07-10
|
||||
note: "Accepted to the IEEE/ACM Conference on Connected Health (CHASE); presentation August 2026."
|
||||
|
||||
- title: "Levshell"
|
||||
section: engineering
|
||||
|
|
@ -88,8 +105,8 @@ entries:
|
|||
- title: "Pmacs"
|
||||
section: engineering
|
||||
status: building
|
||||
updated: 2026-04-20
|
||||
note: "Emacs-inspired IDE in Zig with first-class parallelism."
|
||||
updated: 2026-07-10
|
||||
note: "Emacs-inspired IDE in Rust with first-class parallelism; migrated from an earlier Zig prototype."
|
||||
|
||||
shipped:
|
||||
- title: "Where Does SIMD Help Post-Quantum Cryptography?"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,658 @@
|
|||
\documentclass[11pt]{amsart}
|
||||
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage{microtype}
|
||||
\usepackage[margin=1in]{geometry}
|
||||
\usepackage{amsmath,amssymb,amsthm}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{hyperref}
|
||||
|
||||
\hypersetup{
|
||||
colorlinks=true,
|
||||
linkcolor=blue!50!black,
|
||||
citecolor=blue!50!black,
|
||||
urlcolor=blue!50!black
|
||||
}
|
||||
|
||||
\newtheorem{theorem}{Theorem}[section]
|
||||
\newtheorem{lemma}[theorem]{Lemma}
|
||||
\newtheorem{proposition}[theorem]{Proposition}
|
||||
\newtheorem{corollary}[theorem]{Corollary}
|
||||
\newtheorem{question}[theorem]{Question}
|
||||
|
||||
\theoremstyle{definition}
|
||||
\newtheorem{definition}[theorem]{Definition}
|
||||
\newtheorem{remark}[theorem]{Remark}
|
||||
|
||||
\DeclareMathOperator{\dist}{dist}
|
||||
\DeclareMathOperator{\supp}{supp}
|
||||
|
||||
\title[Branch-Tube Persistence and Static Coverage]{Branch-Tube Persistence and Static Coverage\\in Tree-Ball Geometry}
|
||||
\author{Levi Neuwirth}
|
||||
\address{Brown University}
|
||||
\date{July 21, 2026}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\begin{abstract}
|
||||
We analyze exhaustive static coverage by path tubes indexed by length-$t$ nonbacktracking robber paths from $v_0$ in a finite-horizon local chase on a $d$-regular graph. An endpoint-sensitive geodesic lemma shows that, among possibly infinite $d$-regular graphs, radius $R+t$ is sharp for arbitrary pairs in $B_R(v_0)\times B_t(v_0)$, while synchronized witnesses along a prescribed path require only a radius-$R$ tree-ball. If every tube is occupied, each surviving round along every path in this class ends in capture, blockage, or branch-load support on at least two branches. The tubes partition the outer ball, so deterministic coverage has minimum cost $N_t=d(d-1)^{t-1}$; conditional on a specified root, i.i.d.\ uniform coverage has threshold $\Theta(N_t\log N_t)$ for fixed $d$. An augmented prefix-depth profile that retains the complete shallow configuration still need not determine later support. The result is local and root-dependent: it treats neither arbitrary robber walks nor a robber-independent cop strategy, and it gives no cop-number bound.
|
||||
\end{abstract}
|
||||
|
||||
\maketitle
|
||||
|
||||
|
||||
\section{Introduction}
|
||||
|
||||
\subsection{Motivation}
|
||||
|
||||
The cops-and-robbers game, introduced independently in its one-cop form by Quilliot and by Nowakowski--Winkler and developed in its multiple-cop form by Aigner--Fromme~\cite{Quilliot,NowakowskiWinkler,AignerFromme}, is a pursuit-evasion game on a graph in which $k$ cops attempt to capture one robber. The minimum such $k$ is the \emph{cop number} $c(G)$. \emph{Meyniel's conjecture}, attributed to Henri Meyniel and appearing in the early literature in Frankl's work~\cite{Frankl}, asserts that $c(G)=O(\sqrt n)$ for every connected graph on $n$ vertices and remains a central open problem in the area. The best general upper bound,
|
||||
\[
|
||||
c(G) \leq \frac{n}{2^{(1-o(1))\sqrt{\log_2 n}}},
|
||||
\]
|
||||
was proved independently by Lu--Peng, Scott--Sudakov, and Frieze--Krivelevich--Loh~\cite{LuPeng,ScottSudakov,FriezeKrivelevichLoh}.
|
||||
|
||||
Pra\l at--Wormald's random-graph argument combines an initial random cop placement with assignments made after the robber's start is revealed: in different regimes, cop teams fully occupy a neighborhood or densely cover a sphere~\cite{PralatWormald}. Tree-like geometry enters the literature in several other ways. Aigner--Fromme show that one moving cop can guard a fixed isometric path~\cite{AignerFromme}. Frankl established a high-girth lower bound~\cite{Frankl}, and Bradshaw--Hosseini--Mohar--Stacho refine that line through a branch-weight argument in which unique local geodesics let the robber choose a sufficiently lightly controlled forward branch~\cite{BradshawHosseiniMoharStacho}. Related bounded-degree reductions provide broader extremal context~\cite{HMG}. These mechanisms motivate a path-conditioned local question: whether cops near the robber can coordinate their distance-decreasing moves along every prescribed length-$t$ nonbacktracking path starting at the robber's initial vertex.
|
||||
|
||||
The present paper instead isolates one rigid, root-dependent certificate: every length-$t$ nonbacktracking path from $v_0$ indexes an occupied descendant tube. Paths here index a static partition of the outer ball rather than a route guarded by one moving cop or an adaptively selected escape branch. We determine this certificate's guarantee and cost. Arbitrary robber walks, including stationary moves and reversals, are not analyzed, and exact tree geometry is not asserted to be necessary.
|
||||
|
||||
The uniform sampling below is conditional on the fixed root $v_0$: the root is specified before cop positions are sampled from $B_R(v_0)$. This measures the local cost of the certificate, not a legal initial placement in the standard game, where cops choose positions before the robber chooses its start. A global strategy would need comparable coverage simultaneously or adaptively for an unknown robber position. Accordingly, the results neither construct a robber-independent global strategy nor improve the cop-number bound.
|
||||
|
||||
\subsection{Setup and endpoint-sensitive tree-ball geodesics}
|
||||
|
||||
Graphs are simple and undirected. The local results allow finite or infinite graphs unless finiteness is stated explicitly; discussion of the cop number and all $n$-vertex asymptotics concerns finite connected graphs. Throughout, $G$ denotes a $d$-regular graph with $d\geq3$, and $v_0\in V(G)$ is the robber's initial position in the rooted local experiment. All radii and time horizons are nonnegative integers, and $t,r\geq1$ whenever length-$t$ or order-$r$ objects are used. All unadorned logarithms are natural. We write
|
||||
\[
|
||||
B_R(v)=\{x\in V(G):\dist(x,v)\leq R\},
|
||||
\qquad
|
||||
S_j(v)=\{x\in V(G):\dist(x,v)=j\}.
|
||||
\]
|
||||
|
||||
\begin{definition}[Tree-ball]
|
||||
\label{def:tree-ball}
|
||||
The ball $B_R(v_0)$ is a \emph{tree-ball} if the induced subgraph $G[B_R(v_0)]$ is a tree.
|
||||
\end{definition}
|
||||
|
||||
This is a local condition at the specified center. The global condition $\operatorname{girth}(G)>2R+1$ implies that every radius-$R$ ball is a tree-ball. Conversely, if every radius-$R$ ball is a tree-ball, then $\operatorname{girth}(G)>2R+1$.
|
||||
|
||||
An induced tree-ball does not control shortest paths between arbitrary pairs of its vertices: a competing path may leave the ball and re-enter. The required radius depends on the endpoint depths and on the length of their path inside the tree-ball.
|
||||
|
||||
\begin{lemma}[Endpoint-sensitive tree-ball geodesics]
|
||||
\label{lem:buffer}
|
||||
Suppose $B_q(v_0)$ is a tree-ball, let $x,y\in B_q(v_0)$, and let $L$ be the length of the $x$--$y$ path in $G[B_q(v_0)]$. If
|
||||
\[
|
||||
\dist(x,v_0)+\dist(y,v_0)+L\leq 2q,
|
||||
\]
|
||||
then every ambient $x$--$y$ geodesic lies in $B_q(v_0)$. Consequently, that geodesic is unique and equals the $x$--$y$ path in $G[B_q(v_0)]$.
|
||||
\end{lemma}
|
||||
|
||||
\begin{proof}
|
||||
Let $P$ be an ambient $x$--$y$ geodesic of length $\ell$. The path in $G[B_q(v_0)]$ gives $\ell\leq L$. For $z\in P$, write $h=\dist_P(x,z)$. Then
|
||||
\[
|
||||
\dist(z,v_0)
|
||||
\leq \min\{\dist(x,v_0)+h,\ \dist(y,v_0)+\ell-h\}
|
||||
\leq \frac{\dist(x,v_0)+\dist(y,v_0)+\ell}{2}
|
||||
\leq q.
|
||||
\]
|
||||
Thus $P\subseteq B_q(v_0)$. Since the induced graph on this ball is a tree, $P$ is its unique $x$--$y$ path.
|
||||
\end{proof}
|
||||
|
||||
\begin{remark}[Uniform buffers versus chase endpoints]
|
||||
For arbitrary $x\in B_R(v_0)$ and $y\in B_t(v_0)$, the walk from $x$ to $y$ through $v_0$ has length at most $R+t$. If $B_{R+t}(v_0)$ is a tree-ball, the path between $x$ and $y$ in the rooted tree $G[B_{R+t}(v_0)]$ is no longer than this walk, so Lemma~\ref{lem:buffer} applies. For $R,t\geq1$, this uniform radius cannot in general be reduced. Put $L=R+t$, begin with the rooted $d$-regular tree truncated at depth $L-1$, and choose vertices $x$ and $y$ at depths $R$ and $t$ in distinct root branches. Choose descendants $a$ of $x$ and $b$ of $y$ in $S_{L-1}(v_0)$, allowing equality, add a new vertex $z$, and join $z$ to $a$ and $b$.
|
||||
|
||||
Complete the graph explicitly: for each remaining degree deficit at a vertex of $S_{L-1}(v_0)\cup\{z\}$, attach by one edge a separate infinite rooted $(d-1)$-ary tree. Every vertex then has degree $d$, and the resulting graph is infinite, simple, and connected. The induced ball $B_{L-1}(v_0)$ is still the original tree. The path from $x$ to $y$ through $v_0$ has length $L$, while the path through $z$ has length
|
||||
\[
|
||||
\dist(x,a)+2+\dist(b,y)=(t-1)+2+(R-1)=L.
|
||||
\]
|
||||
Each attached infinite tree meets the displayed core at only one vertex, so it creates no additional route between $x$ and $y$. Thus the two displayed paths are distinct geodesics. This proves sharpness at radius $R+t-1$ among possibly infinite $d$-regular graphs.
|
||||
|
||||
The endpoint pairs used along a prescribed length-$t$ nonbacktracking path are more restricted. Their rooted-tree path and endpoint depths satisfy Lemma~\ref{lem:buffer} with $q=R$, so the persistence result requires no tree-ball beyond the cop-placement radius. This does not assert that the radius-$R$ tree-ball hypothesis is necessary. Controlled cyclic geometry is not addressed here, and no cyclic extension is proved.
|
||||
\end{remark}
|
||||
|
||||
Whenever $B_q(v_0)$ is a tree-ball, we root $G[B_q(v_0)]$ at $v_0$. Every vertex other than $v_0$ has a unique parent, and every vertex of depth less than $q$ has $d-1$ children.
|
||||
|
||||
\begin{definition}[Geodesic cone]
|
||||
\label{def:cone}
|
||||
Suppose $B_r(v)$ is a tree-ball and $u\in N(v)$. The \emph{geodesic cone} through $u$ at radius $r$ is
|
||||
\[
|
||||
C_u(v,r):=\{x\in B_r(v)\setminus\{v\}:\text{the path from $x$ to $v$ in $B_r(v)$ has penultimate vertex $u$}\}.
|
||||
\]
|
||||
\end{definition}
|
||||
|
||||
\begin{definition}[Path tube]
|
||||
\label{def:tube}
|
||||
Let $R\geq t$, suppose $B_R(v_0)$ is a tree-ball, and let
|
||||
\[
|
||||
\sigma=(v_0,v_1,\ldots,v_t)
|
||||
\]
|
||||
be a length-$t$ nonbacktracking path starting at $v_0$: consecutive vertices are adjacent and $v_{i+1}\neq v_{i-1}$ whenever the latter condition is defined. The \emph{length-$t$ tube} associated with $\sigma$ is
|
||||
\[
|
||||
T_\sigma(R):=\{x\in B_R(v_0):\text{the rooted path from $x$ to $v_0$ contains $v_t,v_{t-1},\ldots,v_1$ in order}\}.
|
||||
\]
|
||||
Equivalently, $T_\sigma(R)$ is the set of descendants of $v_t$ lying in $B_R(v_0)$.
|
||||
\end{definition}
|
||||
|
||||
\subsection{Game conventions}
|
||||
|
||||
A \emph{cop configuration} $X$ is a finite multiset of vertices, represented by its finitely supported multiplicity function $X(v)\in\mathbb Z_{\geq0}$. For $A\subseteq V(G)$, set
|
||||
\[
|
||||
X(A):=\sum_{v\in A}X(v),
|
||||
\qquad
|
||||
|X|:=X(V(G)),
|
||||
\qquad
|
||||
\supp(X):=\{v:X(v)>0\}.
|
||||
\]
|
||||
Thus counts of cops include multiplicity, several cops may occupy one vertex, and $A$ is occupied exactly when $X(A)>0$. We write $X=[x_1,\ldots,x_k]$ for a multiset of cop positions, with repeated entries allowed. Independent uniform sampling is with replacement and produces the multiset of sampled positions.
|
||||
|
||||
Capture occurs whenever a cop and the robber occupy the same vertex, including before the first cop move; once capture occurs, no further move is made. Conditional on no capture, the cops move first in each round, simultaneously, and each cop moves to a neighboring vertex minimizing its distance to the robber's current vertex. Ties may be broken arbitrarily. Some formulations also allow cops to pass; the results remain valid there by having the selected witnesses make the prescribed distance-decreasing moves. Under the tree-ball hypotheses below, Lemma~\ref{lem:buffer} shows that each witness selected in our proofs has a unique distance-decreasing move.
|
||||
|
||||
We analyze only length-$t$ nonbacktracking robber paths starting at $v_0$. In the full game a robber walk may reverse an edge or, under common conventions, remain stationary. Neither behavior is covered here. Fix a prescribed path
|
||||
\[
|
||||
\tau=(v_0,v_1,\ldots,v_t).
|
||||
\]
|
||||
It survives through round $0$ precisely when no initial capture occurs. If it has survived through round $s-1$, then the robber is at $v_{s-1}$ when round $s$ begins. After the cops move toward $v_{s-1}$, the robber is captured if a cop occupies $v_{s-1}$. Otherwise the prescribed move $v_{s-1}\to v_s$ is \emph{legal} if no cop occupies $v_s$ after the cop move. The path survives through round $s$ if no capture or blockage has occurred through that move.
|
||||
|
||||
For a robber position $v$ and a neighbor $u\in N(v)$, an individual cop at $c\neq v$ whose $c$--$v$ geodesic is unique \emph{contributes to branch $u$ at $v$} if that geodesic has penultimate vertex $u$. The \emph{branch-load support} at $v$ is the set of branches receiving at least one contributing cop; its size counts branches, not cops.
|
||||
|
||||
\subsection{Main results and scope}
|
||||
|
||||
Although $R+t$ is the sharp uniform radius for arbitrary endpoint pairs in $B_R(v_0)\times B_t(v_0)$, the synchronized pairs arising along a prescribed length-$t$ nonbacktracking path satisfy the endpoint-sensitive criterion already at radius $R$. Accordingly, the persistence theorem assumes only that the cop-placement ball $B_R(v_0)$ is a tree-ball.
|
||||
|
||||
Exact rooted-tree counts identify first-level cones as the $t=1$ path tubes and show that the length-$t$ tubes partition the outer ball. Conditional on the prescribed path continuing, a witness's initial depth determines a potential capture or blockage time within the horizon or certifies descendant pressure throughout it. This yields a persistence theorem uniform over all length-$t$ nonbacktracking paths starting at $v_0$, and only over that class.
|
||||
|
||||
The next results quantify the price of exhaustive static coverage. Exactly
|
||||
\[
|
||||
N_t=d(d-1)^{t-1}
|
||||
\]
|
||||
cops are necessary and sufficient to occupy every tube at a fixed root. Along fixed-degree sequences with $t\to\infty$, conditional uniform sampling has a threshold of order $N_t\log N_t$; its leading factor depends explicitly on $R-t$ and tends to $1$ when $R-t\to\infty$.
|
||||
|
||||
Finally, Section~\ref{sec:profile-loss} records information lost by finite-order tube profiles. Even after retaining every shallow cop multiplicity, such a profile need not determine branch support after $r+1$ rounds. This exact nondeterminacy does not rule out one-sided certificates that reject ambiguous profile fibers.
|
||||
|
||||
\section{Cone and Tube Counts}
|
||||
|
||||
\begin{lemma}[Sharp shell and cone counts]
|
||||
\label{lem:shell}
|
||||
Let $G$ be $d$-regular with $d\geq3$, and suppose $B_r(v)$ is a tree-ball. Then for every $u\in N(v)$ and $1\leq j\leq r$,
|
||||
\[
|
||||
|C_u(v,r)\cap S_j(v)|=(d-1)^{j-1},
|
||||
\qquad
|
||||
|S_j(v)|=d(d-1)^{j-1}.
|
||||
\]
|
||||
Hence
|
||||
\[
|
||||
|C_u(v,r)|=\sum_{j=1}^r(d-1)^{j-1}
|
||||
=\frac{(d-1)^r-1}{d-2},
|
||||
\]
|
||||
and
|
||||
\[
|
||||
|B_r(v)|=1+d\frac{(d-1)^r-1}{d-2}.
|
||||
\]
|
||||
\end{lemma}
|
||||
|
||||
\begin{proof}
|
||||
The branch rooted at $u$ is a $(d-1)$-ary rooted tree of depth $r-1$. Its level $j-1$ contains $(d-1)^{j-1}$ vertices, giving the first formula. Summing over the $d$ branches gives the shell count, and the remaining identities follow by summing the geometric series.
|
||||
\end{proof}
|
||||
|
||||
For $t=1$, geodesic cones are exactly the path tubes. We now count the general length-$t$ objects that drive the persistence argument.
|
||||
|
||||
|
||||
\begin{lemma}[Tube count and partition]
|
||||
\label{lem:tube-partition}
|
||||
Let $R\geq t$, and suppose $B_R(v_0)$ is a tree-ball. For every length-$t$ nonbacktracking path $\sigma$ starting at $v_0$,
|
||||
\[
|
||||
|T_\sigma(R)|
|
||||
=\sum_{j=t}^R(d-1)^{j-t}
|
||||
=\frac{(d-1)^{R-t+1}-1}{d-2}.
|
||||
\]
|
||||
The length-$t$ tubes are pairwise disjoint and form a partition
|
||||
\[
|
||||
B_R(v_0)\setminus B_{t-1}(v_0)
|
||||
=\bigsqcup_{\sigma}T_\sigma(R),
|
||||
\]
|
||||
where $\sigma$ ranges over all length-$t$ nonbacktracking paths from $v_0$. Their number is
|
||||
\[
|
||||
N_t=d(d-1)^{t-1}.
|
||||
\]
|
||||
\end{lemma}
|
||||
|
||||
\begin{proof}
|
||||
At total depth $j\geq t$, the descendants of the terminal vertex $v_t$ form the depth-$(j-t)$ level of a $(d-1)$-ary rooted tree and hence contribute $(d-1)^{j-t}$ vertices. Summing gives the tube size.
|
||||
|
||||
Every vertex of depth at least $t$ has a unique rooted prefix of length $t$, so it lies in exactly one tube. The first edge of such a prefix has $d$ choices, and every later edge has $d-1$ choices, giving $N_t=d(d-1)^{t-1}$.
|
||||
\end{proof}
|
||||
|
||||
For a fixed rooted tree-ball $(G,v_0,R)$, after the root has been specified, let $C$ be uniform on $B_R(v_0)$. The probability that $C$ lies in a prescribed length-$t$ tube is
|
||||
\[
|
||||
q_{t,R}:=\Pr(C\in T_\sigma(R)\mid G,v_0,R)
|
||||
=\frac{|T_\sigma(R)|}{|B_R(v_0)|}.
|
||||
\]
|
||||
The partition gives the exact identity
|
||||
\begin{equation}
|
||||
\label{eq:tube-mass}
|
||||
N_tq_{t,R}
|
||||
=1-\frac{|B_{t-1}(v_0)|}{|B_R(v_0)|}.
|
||||
\end{equation}
|
||||
Put $h=R-t$ and $b=d-1$. Lemmas~\ref{lem:shell} and~\ref{lem:tube-partition} give
|
||||
\[
|
||||
q_{t,R}=\frac{b^{h+1}-1}{(b+1)b^{t+h}-2}
|
||||
\]
|
||||
and
|
||||
\[
|
||||
\frac{1}{N_tq_{t,R}}
|
||||
=\frac{b^{h+1}-2/((b+1)b^{t-1})}{b^{h+1}-1}
|
||||
=\frac{1}{1-b^{-(h+1)}}+O(b^{-t}),
|
||||
\]
|
||||
uniformly for $h\geq0$. Thus $q_{t,R}=\Theta(N_t^{-1})$ uniformly over $R\geq t$, but its leading constant depends on $R-t$ when that difference remains bounded.
|
||||
|
||||
\section{Path-Tube Persistence}
|
||||
|
||||
\subsection{The interception schedule}
|
||||
|
||||
\begin{lemma}[Interception schedule along a tube]
|
||||
\label{lem:interception}
|
||||
Let $R\geq t$, suppose $B_R(v_0)$ is a tree-ball, and let
|
||||
\[
|
||||
\sigma=(v_0,v_1,\ldots,v_t)
|
||||
\]
|
||||
be a length-$t$ nonbacktracking path starting at $v_0$. Let a cop start at $c\in T_\sigma(R)$ at depth $j=\dist(c,v_0)$. Suppose the robber follows $\sigma$ for as long as the path survives. For every $s\in\{1,\ldots,t\}$ such that the path has survived through round $s-1$, after the cop move in round $s$:
|
||||
\begin{enumerate}[label=(\roman*)]
|
||||
\item if $j=2s-1$, the cop reaches $v_{s-1}$ and captures the robber;
|
||||
\item if $j=2s$, the cop reaches $v_s$ and blocks the intended move;
|
||||
\item if $j\geq2s+1$, the cop remains a strict descendant of $v_s$.
|
||||
\end{enumerate}
|
||||
The alternative $j\leq2s-2$ cannot occur: in that case the path was already captured or blocked by the end of round $s-1$.
|
||||
\end{lemma}
|
||||
|
||||
\begin{proof}
|
||||
Set $s_*=\lceil j/2\rceil$. For every reached round $k\leq\min\{s_*,t\}$, an induction shows that before the cop move the cop is a descendant of $v_{k-1}$ and has depth $j-k+1$. This is true at $k=1$. At round $k$, the endpoint-depth sum for the cop and $v_{k-1}$ is $j$, while their rooted-path length is $j-2k+2$. Hence the left side of Lemma~\ref{lem:buffer}'s inequality, with $q=R$, is
|
||||
\[
|
||||
j+(j-2k+2)=2(j-k+1)\leq2j\leq2R.
|
||||
\]
|
||||
The rooted path is therefore the unique ambient geodesic, and the cop moves one step toward $v_0$, to depth $j-k$. If $k<s_*$, then $j-k\geq k+1$, so the cop remains a strict descendant of $v_k$, proving the induction step.
|
||||
|
||||
At $k=s_*$, if $j$ is odd then $j=2s_*-1$ and the cop reaches $v_{s_*-1}$; if $j$ is even then $j=2s_*$ and the cop reaches $v_{s_*}$. This proves the three cases. If $j\leq2s-2$, then $s_*\leq s-1$, so the scheduled capture or blockage prevents survival through round $s-1$.
|
||||
\end{proof}
|
||||
|
||||
\begin{remark}
|
||||
Odd and even initial depths encode potential capture and blockage times, respectively. If the corresponding time is at most $t$ and the prescribed play reaches it, the event occurs then. If $\lceil j/2\rceil>t$, that time lies beyond the horizon and the cop remains descendant pressure throughout the first $t$ reached rounds.
|
||||
\end{remark}
|
||||
|
||||
\subsection{Persistence along nonbacktracking paths}
|
||||
|
||||
\begin{theorem}[Nonbacktracking path-tube persistence]
|
||||
\label{thm:persistence}
|
||||
Let $d\geq3$ and $1\leq t\leq R$, suppose $B_R(v_0)$ is a tree-ball, and let $X_0$ be any initial cop configuration satisfying
|
||||
\[
|
||||
X_0(T_\sigma(R))\geq1
|
||||
\]
|
||||
for every length-$t$ tube. Cops outside $B_R(v_0)$ are permitted; the proof ignores them unless they cause capture, blockage, or additional branch support. If $X_0(\{v_0\})>0$, the robber is captured before any move. Otherwise, let
|
||||
\[
|
||||
\tau=(v_0,v_1,\ldots,v_t)
|
||||
\]
|
||||
be any length-$t$ nonbacktracking path starting at $v_0$. At every round $s\in\{1,\ldots,t\}$ for which the robber has followed $\tau$ through round $s-1$, at least one of the following occurs during round $s$:
|
||||
\begin{enumerate}[label=(\alph*)]
|
||||
\item on the cops' move, a cop captures the robber at $v_{s-1}$;
|
||||
\item after the cops' move, the robber is not captured, but a cop occupies $v_s$, so the intended move is illegal;
|
||||
\item the move to $v_s$ is legal, and after that move the branch-load support at $v_s$ has size at least $2$.
|
||||
\end{enumerate}
|
||||
\end{theorem}
|
||||
|
||||
\begin{proof}
|
||||
Assume there was no initial capture, and fix a round $s$ reached along $\tau$. If a cop reaches $v_{s-1}$, then (a) holds. If no cop captures the robber but a cop occupies $v_s$, then (b) holds. Assume instead that the move to $v_s$ is legal and produce two branch witnesses.
|
||||
|
||||
\emph{Descendant witness.}
|
||||
Choose a cop $c^\downarrow$ from $T_\tau(R)$ and let $j_\downarrow$ be its initial depth. Survival through round $s-1$ rules out $j_\downarrow\leq2s-2$, while the failure of alternatives~(a) and~(b) rules out $j_\downarrow\in\{2s-1,2s\}$. Hence $j_\downarrow\geq2s+1$, and Lemma~\ref{lem:interception} implies that after the round-$s$ cop move it is a strict descendant of $v_s$, at depth $j_\downarrow-s$. Its rooted path to $v_s$ has length $j_\downarrow-2s$, so the endpoint-depth sum plus this length is
|
||||
\[
|
||||
(j_\downarrow-s)+s+(j_\downarrow-2s)
|
||||
=2(j_\downarrow-s)\leq2R.
|
||||
\]
|
||||
Lemma~\ref{lem:buffer}, with $q=R$, makes this the unique ambient geodesic. Thus the cop contributes to a child branch of $v_s$.
|
||||
|
||||
\emph{Parent witness.}
|
||||
Choose $u_0\in N(v_0)\setminus\{v_1\}$, extend $(v_0,u_0)$ to a length-$t$ nonbacktracking path $\sigma'$, and choose a cop $c^\uparrow\in T_{\sigma'}(R)$ of initial depth $j$. Then $R\geq j\geq t\geq s$. We prove by induction on $k\in\{1,\ldots,s\}$ that before its move in round $k$, the cop has depth $j-k+1$ and lies in the root branch through $u_0$. This is immediate for $k=1$. At round $k$, the rooted path from the cop to $v_{k-1}$ passes through $v_0$; both its length and the sum of the endpoint depths equal
|
||||
\[
|
||||
(j-k+1)+(k-1)=j.
|
||||
\]
|
||||
Lemma~\ref{lem:buffer}, with $q=R$, therefore forces one step toward $v_0$. If $k<s$, then $j-k\geq t-s+1\geq1$, so the cop remains in the $u_0$-branch and the induction continues.
|
||||
|
||||
After the move in round $s$, the cop has depth $j-s$. It either remains in the $u_0$-branch or, when $j=s$, is at $v_0$. Its rooted path to $v_s$ enters through $v_{s-1}$, and both the path length and endpoint-depth sum equal $(j-s)+s=j$. Lemma~\ref{lem:buffer} again makes this the unique ambient geodesic, so the cop contributes to the parent branch of $v_s$. If $j=s=1$, the cop instead captures at $v_0$, a case already excluded.
|
||||
|
||||
The two witnesses contribute to distinct branches, proving (c).
|
||||
\end{proof}
|
||||
|
||||
\subsection{The one-round case}
|
||||
|
||||
\begin{corollary}[One-round persistence]
|
||||
\label{cor:one-round}
|
||||
Let $R\geq1$, suppose $B_R(v_0)$ is a tree-ball, and let $X_0$ be a cop configuration satisfying
|
||||
\[
|
||||
X_0(C_u(v_0,R))\geq1
|
||||
\qquad\text{for every }u\in N(v_0).
|
||||
\]
|
||||
For every proposed neighboring first move $v_0\to w$, the robber is captured at $v_0$ (initially or on the first cop move), blocked at $w$, or, after a legal move to $w$, faces branch-load support of size at least two.
|
||||
\end{corollary}
|
||||
|
||||
\begin{proof}
|
||||
Initial capture gives the first alternative. Otherwise apply Theorem~\ref{thm:persistence} with $t=1$.
|
||||
\end{proof}
|
||||
|
||||
|
||||
\section{The Cost of Exhaustive Tube Coverage}
|
||||
|
||||
Theorem~\ref{thm:persistence} uses a strong static hypothesis: every length-$t$ nonbacktracking path starting at $v_0$ indexes an occupied tube. We now quantify the exact deterministic cost and the conditional random-sampling threshold of this local certificate. These costs are not cop-number bounds and do not establish a robber-independent global placement.
|
||||
|
||||
\begin{proposition}[Deterministic coverage cost]
|
||||
\label{prop:deterministic-cost}
|
||||
Suppose $B_R(v_0)$ is a tree-ball with $R\geq t$, and let $X$ be any cop configuration. Then $X$ occupies every length-$t$ tube if and only if
|
||||
\[
|
||||
X(T_\sigma(R))\geq1
|
||||
\]
|
||||
for every member of the family of $N_t=d(d-1)^{t-1}$ pairwise disjoint tubes. Consequently:
|
||||
\begin{enumerate}[label=(\roman*)]
|
||||
\item every such configuration satisfies $|X|\geq N_t$, counting cops with multiplicity;
|
||||
\item equality suffices for this occupancy property, by choosing one cop position from each tube.
|
||||
\end{enumerate}
|
||||
\end{proposition}
|
||||
|
||||
\begin{proof}
|
||||
This is immediate from Lemma~\ref{lem:tube-partition}.
|
||||
\end{proof}
|
||||
|
||||
\begin{theorem}[Conditional uniform-sampling threshold]
|
||||
\label{thm:sampling-threshold}
|
||||
Fix $d\geq3$. For each positive integer $t$, let $G_t$ be a $d$-regular graph with distinguished vertex $v_{0,t}$, and let $R_t\geq t$ be such that $B_{R_t}^{G_t}(v_{0,t})$ is a tree-ball. Let $\mathcal P_t$ be the set of length-$t$ nonbacktracking paths from $v_{0,t}$, and let $T_{\sigma,t}$ be the tube of $\sigma\in\mathcal P_t$ in that ball. Set
|
||||
\[
|
||||
N_t:=|\mathcal P_t|=d(d-1)^{t-1},
|
||||
\qquad
|
||||
q_t:=\frac{|T_{\sigma,t}|}{|B_{R_t}^{G_t}(v_{0,t})|}.
|
||||
\]
|
||||
For each $t$, after the rooted ball has been fixed, sample $m_t$ positions independently and uniformly with replacement from $B_{R_t}^{G_t}(v_{0,t})$. Let $\mathcal C_t$ be the event that every $T_{\sigma,t}$ contains at least one sample. For every fixed $\varepsilon$ with $0<\varepsilon<1$:
|
||||
\begin{enumerate}[label=(\roman*)]
|
||||
\item if
|
||||
\[
|
||||
m_t\geq(1+\varepsilon)\frac{\log N_t}{q_t},
|
||||
\]
|
||||
then $\Pr(\mathcal C_t)\to1$;
|
||||
\item if
|
||||
\[
|
||||
m_t\leq(1-\varepsilon)\frac{\log N_t}{q_t},
|
||||
\]
|
||||
then $\Pr(\mathcal C_t)\to0$.
|
||||
\end{enumerate}
|
||||
Writing $h_t=R_t-t$, one has, uniformly over $h_t\geq0$,
|
||||
\[
|
||||
\frac{\log N_t}{q_t}
|
||||
=\left(\frac{1}{1-(d-1)^{-(h_t+1)}}+o(1)\right)N_t\log N_t.
|
||||
\]
|
||||
If $h_t=k$ eventually, the leading factor relative to $N_t\log N_t$ is $[1-(d-1)^{-(k+1)}]^{-1}$; if $h_t\to\infty$, it tends to $1$. Uniformly over $R_t\geq t$,
|
||||
\[
|
||||
\frac{\log N_t}{q_t}
|
||||
=\Theta(N_t\log N_t)
|
||||
=\Theta\bigl((d-1)^{t-1}t\bigr).
|
||||
\]
|
||||
\end{theorem}
|
||||
|
||||
\begin{proof}
|
||||
For a fixed $\sigma\in\mathcal P_t$, the probability that $T_{\sigma,t}$ receives no sample is $(1-q_t)^{m_t}$. Hence
|
||||
\[
|
||||
\Pr(\mathcal C_t^{\mathsf c})
|
||||
\leq N_t(1-q_t)^{m_t}
|
||||
\leq N_te^{-m_tq_t}.
|
||||
\]
|
||||
Under (i), this is at most $N_t^{-\varepsilon}\to0$.
|
||||
|
||||
For (ii), let
|
||||
\[
|
||||
Z_t:=\sum_{\sigma\in\mathcal P_t}I_{\sigma,t},
|
||||
\qquad
|
||||
I_{\sigma,t}:=\mathbf 1_{\{T_{\sigma,t}\text{ receives no sample}\}}.
|
||||
\]
|
||||
Then $\mathbb E Z_t=N_t(1-q_t)^{m_t}$. For distinct $\sigma,\tau\in\mathcal P_t$, disjointness gives
|
||||
\[
|
||||
\mathbb E[I_{\sigma,t}I_{\tau,t}]
|
||||
=(1-2q_t)^{m_t}
|
||||
\leq(1-q_t)^{2m_t}
|
||||
=\mathbb E I_{\sigma,t}\,\mathbb E I_{\tau,t}.
|
||||
\]
|
||||
Thus
|
||||
\[
|
||||
\operatorname{Var}(Z_t)
|
||||
\leq\sum_{\sigma\in\mathcal P_t}\operatorname{Var}(I_{\sigma,t})
|
||||
\leq\mathbb E Z_t.
|
||||
\]
|
||||
Since $q_t=\Theta(N_t^{-1})$, one has $q_t\to0$, and under (ii),
|
||||
\[
|
||||
\mathbb E Z_t
|
||||
\geq N_t(1-q_t)^{(1-\varepsilon)(\log N_t)/q_t}
|
||||
=N_t^{\varepsilon+o(1)}\longrightarrow\infty.
|
||||
\]
|
||||
Chebyshev's inequality therefore yields
|
||||
\[
|
||||
\Pr(\mathcal C_t)
|
||||
\leq\frac{\operatorname{Var}(Z_t)}{(\mathbb E Z_t)^2}
|
||||
\leq\frac1{\mathbb E Z_t}
|
||||
\longrightarrow0.
|
||||
\]
|
||||
Finally, the exact calculation preceding the theorem, with $R=R_t$, gives
|
||||
\[
|
||||
\frac1{N_tq_t}
|
||||
=\frac1{1-(d-1)^{-(h_t+1)}}+O((d-1)^{-t})
|
||||
\]
|
||||
uniformly for $h_t\geq0$, proving the remaining assertions.
|
||||
\end{proof}
|
||||
|
||||
\begin{corollary}[Finite horizon from polylogarithmic conditional sampling]
|
||||
\label{cor:polylog-horizon}
|
||||
Fix $d\geq3$. Let $(G_n)$ be a sequence of $n$-vertex $d$-regular graphs with distinguished vertices $v_{0,n}$ and integers $1\leq t_n\leq R_n$ such that $B_{R_n}^{G_n}(v_{0,n})$ is a tree-ball. After each rooted ball is fixed, sample $m_n$ positions independently and uniformly with replacement from it, and let $\mathcal C_n$ be the event that every length-$t_n$ tube rooted at $v_{0,n}$ is occupied.
|
||||
|
||||
Call $(m_n)$ \emph{polylogarithmic} if $m_n=O((\log n)^C)$ for some fixed $C>0$. If $(m_n)$ is polylogarithmic and $\Pr(\mathcal C_n)\to1$, then $t_n=O(\log\log n)$. If $t_n=\Theta(\log n)$ and $\Pr(\mathcal C_n)\to1$, then
|
||||
\[
|
||||
m_n=\Omega(N_{t_n}\log N_{t_n}),
|
||||
\qquad
|
||||
N_{t_n}=d(d-1)^{t_n-1};
|
||||
\]
|
||||
in particular, $m_n\geq n^c$ eventually for some $c>0$.
|
||||
\end{corollary}
|
||||
|
||||
\begin{proof}
|
||||
If $t_n\neq O(\log\log n)$, pass to a subsequence on which $t_n/\log\log n\to\infty$, and then to a further subsequence on which $t_n$ is strictly increasing. Apply the proof of Theorem~\ref{thm:sampling-threshold} along this subsequence, with $t$ replaced by $t_n$. Now $t_n\to\infty$ and $N_{t_n}=(\log n)^{\omega(1)}$. Because the threshold is $\Theta(N_{t_n}\log N_{t_n})$ uniformly in $R_n\geq t_n$, polylogarithmic $m_n$ satisfies
|
||||
\[
|
||||
m_n\leq\frac12\frac{\log N_{t_n}}{q_n}
|
||||
\]
|
||||
eventually on this subsequence, where $q_n$ is the common tube mass. Part~(ii) of Theorem~\ref{thm:sampling-threshold}, with $\varepsilon=1/2$, then gives $\Pr(\mathcal C_n)\to0$, a contradiction. Hence $t_n=O(\log\log n)$.
|
||||
|
||||
If $t_n=\Theta(\log n)$ and $m_n=o(N_{t_n}\log N_{t_n})$ along a subsequence, pass if necessary to a further subsequence with strictly increasing horizons and apply the same subsequence argument. This contradicts $\Pr(\mathcal C_n)\to1$. Thus $m_n=\Omega(N_{t_n}\log N_{t_n})$. Since $N_{t_n}=n^{\Omega(1)}$, this is at least $n^c$ eventually for some $c>0$.
|
||||
\end{proof}
|
||||
|
||||
\begin{remark}[Partial coverage is cheaper]
|
||||
\label{rem:partial-coverage}
|
||||
At one fixed, known root, the expected fraction of length-$t$ tubes occupied by $m$ independent uniform samples is
|
||||
\[
|
||||
1-(1-q_{t,R})^m.
|
||||
\]
|
||||
Since $q_{t,R}=\Theta(N_t^{-1})$, $m=\Theta(N_t)$ samples occupy a fixed positive fraction of the tubes in expectation, while $m=\omega(N_t)$ occupies a $1-o(1)$ fraction in expectation. Under uniform sampling, reducing the expected number of empty tubes to $O(1)$ requires $\Theta(N_t\log N_t)$ samples; the same order is required to leave at most $O(1)$ tubes empty with probability tending to one. These are occupancy statements only: they give neither an adversarial guarantee over all paths from that root nor a root-independent placement, and they do not show that partial coverage supports an adaptive chase.
|
||||
\end{remark}
|
||||
|
||||
\section{Information Loss in Tube Profiles}
|
||||
\label{sec:profile-loss}
|
||||
|
||||
Fix integers $Q\geq r\geq1$ and $k\geq0$, and suppose $B_Q(v_0)$ is a tree-ball. Let $\mathcal P_r(v_0)$ be the set of length-$r$ nonbacktracking paths starting at $v_0$, and let
|
||||
\[
|
||||
\mathcal C_{Q,k}(v_0)
|
||||
:=\{X: X\text{ is a $k$-cop multiset with }\supp(X)\subseteq B_Q(v_0)\}.
|
||||
\]
|
||||
For $X\in\mathcal C_{Q,k}(v_0)$, $\sigma\in\mathcal P_r(v_0)$, and $r\leq j\leq Q$, set
|
||||
\[
|
||||
N_X(\sigma,j)
|
||||
:=\sum_{\substack{x\in S_j(v_0)\\
|
||||
\text{the rooted path from $v_0$ to $x$ begins with }\sigma}}X(x).
|
||||
\]
|
||||
The \emph{augmented order-$r$ tube profile} is
|
||||
\[
|
||||
\Pi_{r;Q,k}(X)
|
||||
:=\left(
|
||||
\bigl(X(x)\bigr)_{x\in B_{r-1}(v_0)},
|
||||
\bigl(N_X(\sigma,j)\bigr)_{\substack{\sigma\in\mathcal P_r(v_0)\\ r\leq j\leq Q}}
|
||||
\right).
|
||||
\]
|
||||
It records the complete configuration through depth $r-1$ and the exact depth histogram in every length-$r$ tube; the $j=r$ coordinates also recover the multiplicities at depth $r$. It forgets only how cops at a fixed greater depth split among descendants below the terminal vertex of a length-$r$ prefix. For an admissible profile $P$, define its fixed-universe fiber by
|
||||
\[
|
||||
\mathcal F_{r;Q,k}(P)
|
||||
:=\{X\in\mathcal C_{Q,k}(v_0):\Pi_{r;Q,k}(X)=P\}.
|
||||
\]
|
||||
|
||||
\begin{proposition}[Augmented tube profiles do not determine later support]
|
||||
\label{prop:profile-nondeterminacy}
|
||||
Fix $d\geq3$ and $r\geq1$, put $Q=2r+3$, and suppose $B_Q(v_0)$ is a tree-ball. There exist $X,Y\in\mathcal C_{Q,2}(v_0)$ and a length-$(r+1)$ nonbacktracking path $\tau=(v_0,\ldots,v_{r+1})$ such that
|
||||
\[
|
||||
\Pi_{r;Q,2}(X)=\Pi_{r;Q,2}(Y).
|
||||
\]
|
||||
The path survives all $r+1$ rounds from both configurations, with all relevant cop moves unique, but immediately after the robber's legal move to $v_{r+1}$ the branch-load supports in $X$ and $Y$ have sizes $2$ and $1$, respectively. Hence, for this fixed path $\tau$, the predicate that the branch-load support after $r+1$ safe rounds has size at least $2$ does not factor through $\Pi_{r;Q,2}$.
|
||||
\end{proposition}
|
||||
|
||||
\begin{proof}
|
||||
Choose $\tau$ and two distinct children $a,b$ of $v_{r+1}$. Choose descendants $x_a$ of $a$ and $x_b$ of $b$ at total depth $Q=2r+3$ from $v_0$, and choose two distinct descendants $y_a,y_a'$ of $a$ at the same total depth. Such choices exist because the vertices at descendant-distance $r+1$ below $a$ number $(d-1)^{r+1}\geq2$. Define
|
||||
\[
|
||||
X=[x_a,x_b],
|
||||
\qquad
|
||||
Y=[y_a,y_a'].
|
||||
\]
|
||||
Both configurations have zero multiplicity on $B_{r-1}(v_0)$, and their only nonzero tube-depth coordinate is the cell indexed by $((v_0,\ldots,v_r),Q)$, where both have value $2$. Thus their augmented profiles agree.
|
||||
|
||||
Every robber position on $\tau$ is an ancestor of every cop in the construction. Immediately before the cop move in round $s$, each cop has total depth $Q-s+1$, while the robber is at $v_{s-1}$. Their rooted path has length $Q-2s+2$, and
|
||||
\[
|
||||
(Q-s+1)+(s-1)+(Q-2s+2)=2Q-2s+2\leq2Q.
|
||||
\]
|
||||
Lemma~\ref{lem:buffer}, with $q=Q$, therefore makes this path the unique ambient geodesic and forces one step upward. After that move, the cop's distances to $v_{s-1}$ and $v_s$ are
|
||||
\[
|
||||
2r+4-2s\geq2
|
||||
\qquad\text{and}\qquad
|
||||
2r+3-2s\geq1
|
||||
\]
|
||||
for $1\leq s\leq r+1$. Thus no capture or blockage occurs. After $r+1$ cop moves, the configurations are $[a,b]$ and $[a,a]$, so the branch-load support sizes after the robber moves to $v_{r+1}$ are $2$ and $1$.
|
||||
\end{proof}
|
||||
|
||||
For $r=1$, the profile includes the multiplicity at $v_0$ as well as the depth histogram in every first-level tube, yet it still does not determine the two-round outcome. Proposition~\ref{prop:profile-nondeterminacy} identifies one ambiguous fiber, not a barrier to every one-sided certificate: a sound certificate may reject that fiber.
|
||||
|
||||
\section{Limitations and Further Directions}
|
||||
\label{sec:open}
|
||||
|
||||
The present arguments do not address five natural directions; no claim of novelty is made for the questions themselves.
|
||||
|
||||
\subsection{Arbitrary robber walks}
|
||||
|
||||
Theorem~\ref{thm:persistence} treats only length-$t$ nonbacktracking paths starting at $v_0$. Stationary moves and reversals destroy the monotone depth evolution used by the interception schedule.
|
||||
|
||||
\begin{question}
|
||||
Can a static or adaptive local certificate give an analogue of Theorem~\ref{thm:persistence} for arbitrary length-$t$ robber walks from $v_0$, including stationary moves and reversals? How do repeated vertices and reversed edges change the required witnesses and coverage cost?
|
||||
\end{question}
|
||||
|
||||
\subsection{Partial and adaptive coverage}
|
||||
|
||||
Remark~\ref{rem:partial-coverage} separates partial from exhaustive conditional coverage at one fixed root. Along a prescribed nonbacktracking path, the persistence proof uses its occupied tube for the descendant witness and a tube with a different first edge for the parent witness; exhaustive coverage supplies both uniformly. A large expected covered fraction alone gives no persistence or adaptive-chase guarantee.
|
||||
|
||||
\begin{question}
|
||||
For a fixed horizon $t$, is there a condition strictly weaker than occupancy of every length-$t$ tube that guarantees capture or a quantified decrease in the number of compatible future nonbacktracking continuations? Can it be updated after each move by reusing witnesses among tubes with a common shorter prefix?
|
||||
\end{question}
|
||||
|
||||
\subsection{Uniformly favorable augmented-profile fibers}
|
||||
|
||||
Fix integers $Q\geq r+1\geq2$ and $k\geq1$, suppose $B_Q(v_0)$ is a tree-ball, and fix a length-$(r+1)$ nonbacktracking path $\tau=(v_0,\ldots,v_{r+1})$. The graph, root, radius, cop number, allowed support region, and allowed distance-minimizing tie-breaks are thereby fixed.
|
||||
|
||||
\begin{question}
|
||||
For which admissible profiles $P$ do both of the following hold?
|
||||
\begin{enumerate}[label=(\roman*)]
|
||||
\item Some $X\in\mathcal F_{r;Q,k}(P)$ and some allowed sequence of cop moves let $\tau$ survive through round $r+1$.
|
||||
\item For every $X\in\mathcal F_{r;Q,k}(P)$ and every allowed sequence of cop moves, the robber is captured or blocked by round $r+1$, or $\tau$ survives and its final branch-load support has size at least $2$.
|
||||
\end{enumerate}
|
||||
\end{question}
|
||||
|
||||
\subsection{Global overlap of tube systems}
|
||||
|
||||
Fix a graph $G$, integers $R\geq t\geq1$, a set $A$ of admissible roots whose radius-$R$ balls are tree-balls, and a root-independent set $W$ of allowed cop locations. For $v\in A$ and a length-$t$ nonbacktracking path $\sigma$ from $v$, write $T_\sigma^v(R)$ for its rooted tube. Define the global tube hypergraph
|
||||
\[
|
||||
\mathcal H_{R,t}(G,A;W)
|
||||
:=\left(W,\{T_\sigma^v(R)\cap W:v\in A,\ \sigma\text{ is a length-$t$ nonbacktracking path from }v\}\right).
|
||||
\]
|
||||
A root-independent set of cop positions supplies the exhaustive tube certificate simultaneously for every $v\in A$ exactly when it is a transversal of $\mathcal H_{R,t}$. This is the local-to-global gap absent from the conditional sampling theorem; multiplicity at an already selected vertex does not improve coverage.
|
||||
|
||||
\begin{question}
|
||||
How do girth, expansion, and nonbacktracking path counts bound the transversal number, fractional transversal number, and codegrees of $\mathcal H_{R,t}$? Can these estimates produce a root-independent placement with controlled cost?
|
||||
\end{question}
|
||||
|
||||
\subsection{Beyond exact tree-balls}
|
||||
|
||||
The persistence proof uses only the unique geodesics of synchronized witnesses inside $B_R(v_0)$. With cycles, rooted branches would have to give way to a shortest-path directed acyclic graph in which paths may split or merge. No persistence theorem or coverage bound is proved in that setting.
|
||||
|
||||
\begin{question}
|
||||
For a radius-$R$ ball obtained from a tree by adding one edge, can geodesic tubes and branch-load support be replaced by notions for which an analogue of Theorem~\ref{thm:persistence} holds? More generally, how do the answer and the minimum exhaustive-coverage cost depend on bounded tree excess?
|
||||
\end{question}
|
||||
|
||||
\section{Conclusion}
|
||||
|
||||
We analyzed a local certificate indexed by length-$t$ nonbacktracking robber paths starting at a fixed root $v_0$ in radius-$R$ tree-ball geometry. The larger radius $R+t$ is sharp, among possibly infinite $d$-regular graphs, for uniform control of arbitrary endpoint pairs, but the synchronized chase witnesses require only $B_R(v_0)$. The length-$t$ tubes partition the outer ball into $N_t=d(d-1)^{t-1}$ parts, and occupying every tube gives the stated capture, blockage, or two-branch-support alternative along every prescribed path in this class.
|
||||
|
||||
At one fixed root the deterministic occupancy cost is $N_t$. Along fixed-degree rooted sequences with $t\to\infty$, the conditional uniform-sampling threshold is
|
||||
\[
|
||||
\left(\frac{1}{1-(d-1)^{-(h_t+1)}}+o(1)\right)N_t\log N_t,
|
||||
\qquad h_t=R_t-t.
|
||||
\]
|
||||
Because the sampling distribution is chosen after the root, it is not a legal standard-game initial placement and gives no cop-number bound. The arbitrary-walk, partial/adaptive, global, augmented-profile, and cyclic directions above are not addressed by the present results.
|
||||
|
||||
\section*{Acknowledgments}
|
||||
I warmly thank Eric Jovinelly, who led me through Graph Theory at Brown and greatly assisted me in building both graph-theoretic and broader mathematical skills and intuition. I also thank my peers in the Spring 2026 S02 of Graph Theory at Brown.
|
||||
|
||||
\begin{thebibliography}{99}
|
||||
|
||||
\bibitem{AignerFromme}
|
||||
M.~Aigner and M.~Fromme,
|
||||
\emph{A game of cops and robbers},
|
||||
Discrete Applied Mathematics, vol.~8, no.~1, 1984, pp.~1--12.
|
||||
|
||||
\bibitem{BradshawHosseiniMoharStacho}
|
||||
P.~Bradshaw, S.~A.~Hosseini, B.~Mohar, and L.~Stacho,
|
||||
\emph{On the cop number of graphs of high girth},
|
||||
Journal of Graph Theory, vol.~102, no.~1, 2023, pp.~15--34.
|
||||
|
||||
\bibitem{Frankl}
|
||||
P.~Frankl,
|
||||
\emph{Cops and robbers in graphs with large girth and Cayley graphs},
|
||||
Discrete Applied Mathematics, vol.~17, no.~3, 1987, pp.~301--305.
|
||||
|
||||
\bibitem{FriezeKrivelevichLoh}
|
||||
A.~Frieze, M.~Krivelevich, and P.-S.~Loh,
|
||||
\emph{Variations on cops and robbers},
|
||||
Journal of Graph Theory, vol.~69, no.~4, 2012, pp.~383--402.
|
||||
|
||||
\bibitem{HMG}
|
||||
S.~A.~Hosseini, B.~Mohar, and S.~Gonzalez Hermosillo de la Maza,
|
||||
\emph{Meyniel's conjecture on graphs of bounded degree},
|
||||
Journal of Graph Theory, vol.~97, no.~3, 2021, pp.~401--407.
|
||||
|
||||
\bibitem{LuPeng}
|
||||
L.~Lu and X.~Peng,
|
||||
\emph{On Meyniel's conjecture of the cop number},
|
||||
Journal of Graph Theory, vol.~71, no.~2, 2012, pp.~192--205.
|
||||
|
||||
\bibitem{NowakowskiWinkler}
|
||||
R.~Nowakowski and P.~Winkler,
|
||||
\emph{Vertex-to-vertex pursuit in a graph},
|
||||
Discrete Mathematics, vol.~43, nos.~2--3, 1983, pp.~235--239.
|
||||
|
||||
\bibitem{PralatWormald}
|
||||
P.~Pra\l at and N.~Wormald,
|
||||
\emph{Meyniel's conjecture holds for random graphs},
|
||||
Random Structures \& Algorithms, vol.~48, no.~2, 2016, pp.~396--421.
|
||||
|
||||
\bibitem{Quilliot}
|
||||
A.~Quilliot,
|
||||
\emph{Jeux et points fixes sur les graphes},
|
||||
Th\`ese de 3\`eme cycle, Universit\'e de Paris~VI, 1978.
|
||||
|
||||
\bibitem{ScottSudakov}
|
||||
A.~Scott and B.~Sudakov,
|
||||
\emph{A bound for the cops and robbers problem},
|
||||
SIAM Journal on Discrete Mathematics, vol.~25, no.~3, 2011, pp.~1438--1442.
|
||||
|
||||
\end{thebibliography}
|
||||
|
||||
\end{document}
|
||||
|
|
@ -159,7 +159,9 @@
|
|||
|
||||
/* Per-status accents — each is intentionally restrained. Active
|
||||
states (in-review, drafting, building) get slightly stronger
|
||||
ink; passive states (paused, shipped) recede. */
|
||||
ink; passive states (paused, shipped) recede. "accepted" is the
|
||||
one affirmative state — full ink and a solid, confident border. */
|
||||
.now-status--accepted { color: var(--text); border-color: var(--text); font-weight: 600; }
|
||||
.now-status--in-review { color: var(--text); border-color: var(--text-muted); }
|
||||
.now-status--revising { color: var(--text); border-color: var(--text-muted); }
|
||||
.now-status--drafting { color: var(--text); }
|
||||
|
|
|
|||
BIN
static/cv.pdf
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 187 KiB |
|
|
@ -22,7 +22,10 @@
|
|||
document.querySelectorAll('.exhibit[data-exhibit-name]').forEach(function (el) {
|
||||
var name = el.dataset.exhibitName || '';
|
||||
var type = el.dataset.exhibitType || 'equation';
|
||||
var id = 'exhibit-' + slugify(name);
|
||||
/* Preserve an author-assigned id (e.g. #thm-persistence, used for
|
||||
cross-references both within the page and from other essays) —
|
||||
only generate one from the name when the element has none. */
|
||||
var id = el.id || ('exhibit-' + slugify(name));
|
||||
el.id = id;
|
||||
exhibits.push({ el: el, type: type, name: name, id: id });
|
||||
});
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 185 KiB |
|
|
@ -0,0 +1,8 @@
|
|||
d,h,dps,B_h,C,alpha_B_h,terminal_t,activity_ratio,minus_psi_over_exp_minus_C,psi_root,psi_direct,route_relative_disagreement,stationarity_residual,telescoping_residual,compact_residual,source_sha256
|
||||
3,12,90,12286.0,2.68891585501040013976745,2.68891585501040013976745,1.5649521817236902514912410604,0.43761910441011800655,0.38704929760879828092,-0.0263017693847861766925997,-0.0263017693847861766925997,8.69648997003e-88,8.28042160528e-171,5.58680599144e-250,1.49001860314e-84,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f
|
||||
3,20,110,3145726.0,6.84510347814699949522998,6.84510347814699949522998,2.59233190755103757476376681408,0.58731966449413053705,0.64837167469537249632,-0.000690292834108247212221875,-0.000690292834108247212221875,2.66900925683e-105,1.14656253968e-538,5.53263399978e-965,1.17925528381e-108,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f
|
||||
3,30,130,3221225470.0,12.6345230553746044619009,12.6345230553746044619009,4.24843522695731804549429347768,0.95204332641006628599,0.94176771565242100741,-0.00000306789261548327935842162,-0.00000306789261548327935842162,5.64116276476e-123,6.75776572086e-2301,4.05185949322e-4470,1.76048737805e-128,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f
|
||||
3,40,160,3298534883326.0,18.7408224021000065352103,18.7408224021000065352103,6.25102199356755672760671381765,0.99562972566514808396,0.97573044303281005292,-7.08425360319143688355618e-9,-7.08425360319143688355618e-9,2.42607964143e-149,6.8404849326e-5339,-1.97357088308e-10516,1.14143107304e-157,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f
|
||||
4,12,100,1062881.0,5.98590477925353559863557,5.98590477925353559863557,3.27430811180715697841994080308,0.68162323087349072876,0.72854684100301358049,-0.00183152168317342646402893,-0.00183152168317342646402893,6.57339657635e-97,5.62858267109e-629,-5.58589608171e-892,4.82863310106e-95,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f
|
||||
4,20,130,6973568801.0,13.3028752886383745882347,13.3028752886383745882347,6.65792202872351594182004835315,0.99149489768252470055,0.97063642934364656966,-0.00000162065754589300041933,-0.00000162065754589300041933,4.39042055762e-122,1.83300664684e-3752,-4.99348900811e-5145,3.38121775554e-130,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f
|
||||
4,28,160,45753584909921.0,21.1086850002637408299362,21.1086850002637408299362,10.5544288265371556225644335766,0.99990750838490069309,0.97999453596106802522,-6.66558450055970876728766e-10,-6.66558450055970876728766e-10,1.6950057681e-148,9.00394642419e-9922,2.57879588178e-13675,7.14127931035e-158,1c3e000366291ab800ca7ee68f13a02ef58de39e945e71ecc81a3a3b02023f5f
|
||||
|
|
|
@ -0,0 +1,570 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reader-facing computations for growing-radius domination.
|
||||
|
||||
This script accompanies the essay version of the preprint
|
||||
|
||||
Near-Critical First-Moment Lower Bounds for
|
||||
Growing-Radius Domination in Random Regular Graphs.
|
||||
|
||||
It deliberately keeps the mathematical structure visible. The core steps are:
|
||||
|
||||
1. compute the tree-ball volume B_h;
|
||||
2. evaluate the conditioned local entropy s_d(a);
|
||||
3. evaluate the exact compact microcanonical functional;
|
||||
4. solve the unique stationary orbit by reverse transfer;
|
||||
5. reconstruct density, activity, and free energy at the root;
|
||||
6. target the near-critical coordinate C = alpha B_h;
|
||||
7. gate every reported row by independent residual checks.
|
||||
|
||||
Only ``mpmath`` is required for the main calculations. ``matplotlib`` is
|
||||
optional and is used only by ``--plot``.
|
||||
|
||||
The code is expository, not optimized for maximum throughput. Numerical
|
||||
calculations illustrate the theorem and audit the identities; they are not
|
||||
used in its proof.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Sequence
|
||||
|
||||
import mpmath as mp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Geometry and the expected scale
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def tree_ball_volume(d: int, h: int) -> mp.mpf:
|
||||
"""Radius-h volume of the infinite d-regular tree."""
|
||||
if d < 3:
|
||||
raise ValueError("d must be at least 3")
|
||||
if h < 0:
|
||||
raise ValueError("h must be nonnegative")
|
||||
b = d - 1
|
||||
return mp.mpf(1) + d * (mp.power(b, h) - 1) / (d - 2)
|
||||
|
||||
|
||||
def near_critical_coordinate(d: int, h: int, W: mp.mpf | None = None) -> mp.mpf:
|
||||
"""Return C = log B_h - 2 log log B_h - W.
|
||||
|
||||
The demonstration default W=log log B_h stays a diverging distance below
|
||||
the bounded critical window while remaining close enough to show the
|
||||
predicted second-order term.
|
||||
"""
|
||||
B = tree_ball_volume(d, h)
|
||||
L = mp.log(B)
|
||||
if W is None:
|
||||
W = mp.log(L)
|
||||
return L - 2 * mp.log(L) - W
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Exact local entropy and compact functional
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _conditioned_marginal(d: int, lam: mp.mpf) -> mp.mpf:
|
||||
"""Marginal of one coordinate in a nonempty tilted subset of [d]."""
|
||||
log1p_lam = mp.log1p(lam)
|
||||
numerator = lam * mp.exp((d - 1) * log1p_lam)
|
||||
denominator = mp.expm1(d * log1p_lam)
|
||||
return numerator / denominator
|
||||
|
||||
|
||||
def lambda_from_marginal(d: int, a: mp.mpf) -> mp.mpf:
|
||||
"""Solve a = lambda(1+lambda)^(d-1)/((1+lambda)^d-1).
|
||||
|
||||
A precision-scaled lower endpoint is important near the Moore corner,
|
||||
where a-1/d can be exponentially small.
|
||||
"""
|
||||
a = mp.mpf(a)
|
||||
lower = mp.mpf(1) / d
|
||||
if a < lower or a > 1:
|
||||
raise ValueError("a must lie in [1/d, 1]")
|
||||
tol = mp.power(10, -(mp.mp.dps - 12))
|
||||
if abs(a - lower) <= tol:
|
||||
return mp.mpf(0)
|
||||
if abs(a - 1) <= tol:
|
||||
return mp.inf
|
||||
|
||||
# Bisection in log lambda avoids an enormous dynamic range.
|
||||
lo = -mp.mpf(2) * mp.mp.dps * mp.log(10)
|
||||
hi = mp.mpf(2) * mp.mp.dps * mp.log(10)
|
||||
for _ in range(max(160, 3 * mp.mp.dps)):
|
||||
mid = (lo + hi) / 2
|
||||
lam = mp.exp(mid)
|
||||
if _conditioned_marginal(d, lam) < a:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return mp.exp((lo + hi) / 2)
|
||||
|
||||
|
||||
def conditioned_subset_entropy(d: int, a: mp.mpf) -> mp.mpf:
|
||||
r"""Maximum entropy s_d(a) on nonempty subsets with marginal a.
|
||||
|
||||
s_d(a) = inf_{lambda>0} [log((1+lambda)^d-1)-da log lambda].
|
||||
"""
|
||||
a = mp.mpf(a)
|
||||
lower = mp.mpf(1) / d
|
||||
tol = mp.power(10, -(mp.mp.dps - 12))
|
||||
if abs(a - lower) <= tol:
|
||||
return mp.log(d)
|
||||
if abs(a - 1) <= tol:
|
||||
return mp.mpf(0)
|
||||
lam = lambda_from_marginal(d, a)
|
||||
log_partition = mp.log(mp.expm1(d * mp.log1p(lam)))
|
||||
return log_partition - d * a * mp.log(lam)
|
||||
|
||||
|
||||
def _xlogx(x: mp.mpf) -> mp.mpf:
|
||||
return mp.mpf(0) if x == 0 else x * mp.log(x)
|
||||
|
||||
|
||||
def compact_microcanonical_value(
|
||||
d: int,
|
||||
h: int,
|
||||
x: Sequence[mp.mpf],
|
||||
ell: Sequence[mp.mpf],
|
||||
) -> tuple[mp.mpf, mp.mpf]:
|
||||
r"""Evaluate the exact compact functional.
|
||||
|
||||
Coordinates:
|
||||
x_i = q_{i-1,i}, i=1,...,h,
|
||||
ell_i = q_{i,i}, i=0,...,h.
|
||||
|
||||
The layer masses are p_i = ell_i + x_i + x_{i+1}, with x_0=x_{h+1}=0.
|
||||
Returns (F, alpha=p_0).
|
||||
"""
|
||||
if len(x) != h or len(ell) != h + 1:
|
||||
raise ValueError("wrong coordinate lengths")
|
||||
xx = [mp.mpf(0)] + [mp.mpf(v) for v in x] + [mp.mpf(0)]
|
||||
ll = [mp.mpf(v) for v in ell]
|
||||
if any(v < 0 for v in xx) or any(v < 0 for v in ll):
|
||||
raise ValueError("coordinates must be nonnegative")
|
||||
|
||||
p = [ll[i] + xx[i] + xx[i + 1] for i in range(h + 1)]
|
||||
if abs(sum(p) - 1) > mp.power(10, -(mp.mp.dps // 2)):
|
||||
raise ValueError("profile is not normalized")
|
||||
|
||||
alpha = p[0]
|
||||
value = (d - 1) * _xlogx(alpha)
|
||||
for i in range(1, h + 1):
|
||||
if p[i] == 0:
|
||||
continue
|
||||
a_i = xx[i] / p[i]
|
||||
lower = mp.mpf(1) / d
|
||||
numerical_tol = mp.power(10, -(mp.mp.dps // 3))
|
||||
if a_i < lower - numerical_tol or a_i > 1 + numerical_tol:
|
||||
raise ValueError("descent marginal is infeasible")
|
||||
a_i = min(mp.mpf(1), max(lower, a_i))
|
||||
y_i = p[i] - xx[i]
|
||||
value += -_xlogx(p[i])
|
||||
value += p[i] * conditioned_subset_entropy(d, a_i)
|
||||
value += d * _xlogx(y_i)
|
||||
value -= mp.mpf(d) / 2 * sum(_xlogx(v) for v in ll)
|
||||
return value, alpha
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Reverse transfer and stationary reconstruction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class StationaryPoint:
|
||||
d: int
|
||||
h: int
|
||||
terminal_t: mp.mpf
|
||||
terminal: mp.mpf
|
||||
z: mp.mpf
|
||||
alpha: mp.mpf
|
||||
phi_direct: mp.mpf
|
||||
psi_direct: mp.mpf
|
||||
phi_root: mp.mpf
|
||||
psi_root: mp.mpf
|
||||
kappa: mp.mpf
|
||||
r0: mp.mpf
|
||||
Zv: mp.mpf
|
||||
Ze: mp.mpf
|
||||
telescoping_residual: mp.mpf
|
||||
root_pressure_residual: mp.mpf
|
||||
root_micro_residual: mp.mpf
|
||||
stationarity_residual: mp.mpf
|
||||
compact_residual: mp.mpf
|
||||
rho: list[mp.mpf]
|
||||
u: list[mp.mpf]
|
||||
A: list[mp.mpf]
|
||||
Bmsg: list[mp.mpf]
|
||||
|
||||
|
||||
def _w_e(rho: mp.mpf, b: int) -> tuple[mp.mpf, mp.mpf]:
|
||||
"""Return w=(1-rho)^(1/b) and e=1-w without cancellation."""
|
||||
log_w = mp.log1p(-rho) / b
|
||||
return mp.exp(log_w), -mp.expm1(log_w)
|
||||
|
||||
|
||||
def reverse_step(rho_next: mp.mpf, v_next: mp.mpf, b: int) -> tuple[mp.mpf, mp.mpf]:
|
||||
r"""Invert one stationary transfer step.
|
||||
|
||||
Input lies in D={(rho,v): 0<rho<=v<=1}. The output is the unique
|
||||
predecessor in D.
|
||||
"""
|
||||
w, e = _w_e(rho_next, b)
|
||||
R = v_next * e / w
|
||||
M = mp.power(e + w / v_next, b)
|
||||
denominator = R + M
|
||||
rho = R / denominator
|
||||
v = (1 + R) / denominator
|
||||
return rho, v
|
||||
|
||||
|
||||
def _stable_power_difference(S: mp.mpf, B: mp.mpf, power: int) -> mp.mpf:
|
||||
"""Compute S^power-(S-B)^power stably when B/S is tiny."""
|
||||
ratio = B / S
|
||||
return mp.power(S, power) * (-mp.expm1(power * mp.log1p(-ratio)))
|
||||
|
||||
|
||||
def stationary_from_terminal_t(
|
||||
d: int, h: int, terminal_t: mp.mpf, *, validate_compact: bool = True
|
||||
) -> StationaryPoint:
|
||||
r"""Construct the unique positive stationary orbit.
|
||||
|
||||
We use t=-log(1-s) rather than s directly. At high density the terminal
|
||||
parameter s is extraordinarily close to one, while t remains numerically
|
||||
well scaled.
|
||||
"""
|
||||
if d < 3 or h < 1:
|
||||
raise ValueError("require d>=3 and h>=1")
|
||||
b = d - 1
|
||||
t = mp.mpf(terminal_t)
|
||||
if t <= 0:
|
||||
raise ValueError("terminal_t must be positive")
|
||||
terminal = -mp.expm1(-t)
|
||||
|
||||
rho = [mp.mpf(0)] * (h + 1)
|
||||
v = [mp.mpf(0)] * (h + 1)
|
||||
u = [mp.mpf(0)] * (h + 1)
|
||||
rho[h] = v[h] = terminal
|
||||
for i in range(h - 1, 0, -1):
|
||||
rho[i], v[i] = reverse_step(rho[i + 1], v[i + 1], b)
|
||||
u[i] = v[i] - rho[i]
|
||||
u[h] = mp.mpf(0)
|
||||
|
||||
w1, e1 = _w_e(rho[1], b)
|
||||
if e1 == 0:
|
||||
raise ArithmeticError("root coordinate under-resolved; increase mp.dps")
|
||||
r0 = v[1] * e1 / w1
|
||||
kappa = mp.power(v[1] / w1, b)
|
||||
z = r0 * kappa / mp.power(1 + r0, b)
|
||||
|
||||
# Normalize A_1=1 and reconstruct the messages.
|
||||
A = [mp.mpf(0)] * (h + 2)
|
||||
Bmsg = [mp.mpf(0)] * (h + 1)
|
||||
A[1] = mp.mpf(1)
|
||||
for i in range(1, h):
|
||||
A[i + 1] = u[i] * A[i]
|
||||
Bmsg[0] = r0
|
||||
for i in range(1, h + 1):
|
||||
Bmsg[i] = rho[i] * A[i]
|
||||
|
||||
S = [mp.mpf(0)] * (h + 1)
|
||||
S[0] = Bmsg[0] + A[1]
|
||||
for i in range(1, h):
|
||||
S[i] = Bmsg[i - 1] + Bmsg[i] + A[i + 1]
|
||||
S[h] = Bmsg[h - 1] + Bmsg[h]
|
||||
|
||||
vertex_terms = [z * mp.power(S[0], d)]
|
||||
for i in range(1, h + 1):
|
||||
vertex_terms.append(_stable_power_difference(S[i], Bmsg[i - 1], d))
|
||||
Zv = mp.fsum(vertex_terms)
|
||||
Ze = mp.fsum(v * v for v in Bmsg) + 2 * mp.fsum(
|
||||
Bmsg[i] * A[i + 1] for i in range(h)
|
||||
)
|
||||
|
||||
alpha = Bmsg[0] * (Bmsg[0] + A[1]) / Ze
|
||||
phi_direct = mp.log(Zv) - mp.mpf(d) / 2 * mp.log(Ze)
|
||||
psi_direct = phi_direct - alpha * mp.log(z)
|
||||
|
||||
# Correct, well-conditioned root-only formulas.
|
||||
phi_root = (
|
||||
mp.log(z)
|
||||
+ mp.mpf(d) / 2 * mp.log((1 + r0) / r0)
|
||||
+ mp.mpf(d - 2) / 2 * mp.log(alpha)
|
||||
)
|
||||
psi_root = (
|
||||
(1 - alpha) * mp.log(kappa)
|
||||
- (mp.mpf(d - 2) / 2 + alpha) * mp.log(r0)
|
||||
+ mp.mpf(d - 2) / 2 * mp.log(alpha)
|
||||
+ (mp.mpf(d - 1) * alpha - mp.mpf(d - 2) / 2) * mp.log(1 + r0)
|
||||
)
|
||||
|
||||
stationarity = [abs(kappa * Bmsg[0] - z * mp.power(S[0], b))]
|
||||
for i in range(1, h + 1):
|
||||
stationarity.append(abs(kappa * A[i] - mp.power(S[i], b)))
|
||||
stationarity.append(
|
||||
abs(
|
||||
kappa * Bmsg[i]
|
||||
- _stable_power_difference(S[i], Bmsg[i - 1], b)
|
||||
)
|
||||
)
|
||||
|
||||
# Reconstruct the compact profile only for final diagnostic points.
|
||||
if validate_compact:
|
||||
x = [Bmsg[i - 1] * A[i] / Ze for i in range(1, h + 1)]
|
||||
ell = [Bmsg[i] * Bmsg[i] / Ze for i in range(h + 1)]
|
||||
compact_value, compact_alpha = compact_microcanonical_value(d, h, x, ell)
|
||||
compact_residual = max(abs(compact_value - psi_root), abs(compact_alpha - alpha))
|
||||
else:
|
||||
compact_residual = mp.nan
|
||||
|
||||
return StationaryPoint(
|
||||
d=d,
|
||||
h=h,
|
||||
terminal_t=t,
|
||||
terminal=terminal,
|
||||
z=z,
|
||||
alpha=alpha,
|
||||
phi_direct=phi_direct,
|
||||
psi_direct=psi_direct,
|
||||
phi_root=phi_root,
|
||||
psi_root=psi_root,
|
||||
kappa=kappa,
|
||||
r0=r0,
|
||||
Zv=Zv,
|
||||
Ze=Ze,
|
||||
telescoping_residual=Zv - kappa * Ze,
|
||||
root_pressure_residual=phi_direct - phi_root,
|
||||
root_micro_residual=psi_direct - psi_root,
|
||||
stationarity_residual=max(stationarity),
|
||||
compact_residual=compact_residual,
|
||||
rho=rho,
|
||||
u=u,
|
||||
A=A,
|
||||
Bmsg=Bmsg,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Target-density solving and residual gates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def solve_for_alpha_B(
|
||||
d: int,
|
||||
h: int,
|
||||
C: mp.mpf,
|
||||
*,
|
||||
iterations: int | None = None,
|
||||
) -> StationaryPoint:
|
||||
"""Solve alpha B_h = C by bisection in terminal_t."""
|
||||
B = tree_ball_volume(d, h)
|
||||
target_alpha = mp.mpf(C) / B
|
||||
if not 0 < target_alpha < 1:
|
||||
raise ValueError("target density must lie in (0,1)")
|
||||
if iterations is None:
|
||||
iterations = max(140, 2 * mp.mp.dps)
|
||||
|
||||
D = mp.mpf(d) / (d - 2)
|
||||
lo = mp.mpf("0.05") * C / D
|
||||
hi = mp.mpf("3.0") * C / D + 1
|
||||
while stationary_from_terminal_t(d, h, lo, validate_compact=False).alpha > target_alpha:
|
||||
lo /= 2
|
||||
while stationary_from_terminal_t(d, h, hi, validate_compact=False).alpha < target_alpha:
|
||||
hi *= 2
|
||||
|
||||
for _ in range(iterations):
|
||||
mid = (lo + hi) / 2
|
||||
point = stationary_from_terminal_t(d, h, mid, validate_compact=False)
|
||||
if point.alpha < target_alpha:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
return stationary_from_terminal_t(d, h, (lo + hi) / 2, validate_compact=True)
|
||||
|
||||
|
||||
def relative_residual(residual: mp.mpf, reference: mp.mpf) -> mp.mpf:
|
||||
return abs(residual) / max(abs(reference), mp.mpf(1))
|
||||
|
||||
|
||||
def gate_point(point: StationaryPoint, route_tolerance: str = "1e-6") -> None:
|
||||
"""Reject a row if an internal cross-check is too large to ignore.
|
||||
|
||||
The direct partition-function route can be ill conditioned near criticality.
|
||||
The root-only formula is the reported value, but the direct route must still
|
||||
agree to the requested relative tolerance at the working precision.
|
||||
"""
|
||||
tol = mp.mpf(route_tolerance)
|
||||
if point.psi_root == 0:
|
||||
raise RuntimeError("zero root-formula microcanonical exponent")
|
||||
route_disagreement = abs(point.root_micro_residual / point.psi_root)
|
||||
if route_disagreement > tol:
|
||||
raise RuntimeError(
|
||||
"microcanonical routes disagree: "
|
||||
f"relative discrepancy={route_disagreement}; increase mp.dps"
|
||||
)
|
||||
if point.compact_residual > mp.sqrt(tol):
|
||||
raise RuntimeError(
|
||||
f"compact-functional reconstruction failed: {point.compact_residual}"
|
||||
)
|
||||
|
||||
|
||||
def diagnostic_row(d: int, h: int, W: mp.mpf | None = None) -> dict[str, str | int]:
|
||||
"""Solve one near-critical case and return an audit-friendly record."""
|
||||
B = tree_ball_volume(d, h)
|
||||
L = mp.log(B)
|
||||
if W is None:
|
||||
W = mp.log(L)
|
||||
C = near_critical_coordinate(d, h, W)
|
||||
point = solve_for_alpha_B(d, h, C)
|
||||
gate_point(point)
|
||||
|
||||
coupon = B * mp.power(1 - point.alpha, B)
|
||||
activity_ratio = (-mp.log(point.z) - mp.log(1 / point.alpha)) / coupon
|
||||
scale = mp.exp(-C)
|
||||
route_disagreement = abs(point.root_micro_residual / point.psi_root)
|
||||
|
||||
return {
|
||||
"d": d,
|
||||
"h": h,
|
||||
"dps": mp.mp.dps,
|
||||
"B_h": mp.nstr(B, 30),
|
||||
"C": mp.nstr(C, 24),
|
||||
"alpha_B_h": mp.nstr(point.alpha * B, 24),
|
||||
"terminal_t": mp.nstr(point.terminal_t, 30),
|
||||
"activity_ratio": mp.nstr(activity_ratio, 20),
|
||||
"minus_psi_over_exp_minus_C": mp.nstr(-point.psi_root / scale, 20),
|
||||
"psi_root": mp.nstr(point.psi_root, 24),
|
||||
"psi_direct": mp.nstr(point.psi_direct, 24),
|
||||
"route_relative_disagreement": mp.nstr(route_disagreement, 12),
|
||||
"stationarity_residual": mp.nstr(point.stationarity_residual, 12),
|
||||
"telescoping_residual": mp.nstr(point.telescoping_residual, 12),
|
||||
"compact_residual": mp.nstr(point.compact_residual, 12),
|
||||
"source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Tables and optional plotting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def default_cases() -> list[tuple[int, int, int]]:
|
||||
"""(d,h,dps) cases chosen to keep a demonstration run manageable."""
|
||||
return [
|
||||
(3, 12, 90),
|
||||
(3, 20, 110),
|
||||
(3, 30, 130),
|
||||
(3, 40, 160),
|
||||
(4, 12, 100),
|
||||
(4, 20, 130),
|
||||
(4, 28, 160),
|
||||
]
|
||||
|
||||
|
||||
def make_table(
|
||||
cases: Iterable[tuple[int, int, int]],
|
||||
*,
|
||||
csv_path: str | None = None,
|
||||
) -> list[dict[str, str | int]]:
|
||||
rows: list[dict[str, str | int]] = []
|
||||
for d, h, dps in cases:
|
||||
mp.mp.dps = dps
|
||||
row = diagnostic_row(d, h)
|
||||
rows.append(row)
|
||||
print(
|
||||
f"d={d} h={h:>2} C={row['C']} "
|
||||
f"activity={row['activity_ratio']} "
|
||||
f"-Psi/e^-C={row['minus_psi_over_exp_minus_C']}"
|
||||
)
|
||||
if csv_path:
|
||||
with open(csv_path, "w", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=list(rows[0].keys()))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def plot_rows(rows: Sequence[dict[str, str | int]], output: str) -> None:
|
||||
"""Plot the two diagnostic ratios; matplotlib is optional."""
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("install matplotlib to use --plot") from exc
|
||||
|
||||
by_d: dict[int, list[dict[str, str | int]]] = {}
|
||||
for row in rows:
|
||||
by_d.setdefault(int(row["d"]), []).append(row)
|
||||
|
||||
# Separate figures keep the diagnostics readable.
|
||||
for field, ylabel, suffix in [
|
||||
("activity_ratio", "activity-law ratio", "activity"),
|
||||
("minus_psi_over_exp_minus_C", r"$-\Psi/e^{-C}$", "free_energy"),
|
||||
]:
|
||||
plt.figure()
|
||||
for d, group in sorted(by_d.items()):
|
||||
group = sorted(group, key=lambda r: int(r["h"]))
|
||||
plt.plot(
|
||||
[int(r["h"]) for r in group],
|
||||
[float(r[field]) for r in group],
|
||||
marker="o",
|
||||
label=f"d={d}",
|
||||
)
|
||||
plt.axhline(1.0, linestyle="--")
|
||||
plt.xlabel("radius h")
|
||||
plt.ylabel(ylabel)
|
||||
plt.legend()
|
||||
plt.tight_layout()
|
||||
path = str(Path(output).with_name(Path(output).stem + f"_{suffix}.png"))
|
||||
plt.savefig(path, dpi=180)
|
||||
plt.close()
|
||||
print(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Command line interface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--d", type=int, default=3)
|
||||
parser.add_argument("--h", type=int, default=20)
|
||||
parser.add_argument("--dps", type=int, default=110)
|
||||
parser.add_argument(
|
||||
"--W",
|
||||
type=str,
|
||||
default=None,
|
||||
help="additive gap W; default is log log B_h",
|
||||
)
|
||||
parser.add_argument("--table", action="store_true", help="run the curated table")
|
||||
parser.add_argument("--csv", type=str, default=None, help="optional CSV output")
|
||||
parser.add_argument("--plot", type=str, default=None, help="plot prefix for table output")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.table:
|
||||
rows = make_table(default_cases(), csv_path=args.csv)
|
||||
if args.plot:
|
||||
plot_rows(rows, args.plot)
|
||||
return
|
||||
|
||||
mp.mp.dps = args.dps
|
||||
W = None if args.W is None else mp.mpf(args.W)
|
||||
row = diagnostic_row(args.d, args.h, W)
|
||||
for key, value in row.items():
|
||||
print(f"{key}: {value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 280 KiB |
|
|
@ -0,0 +1,265 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
MODULE_PATH = REPO_ROOT / "tools" / "import-content.py"
|
||||
SPEC = importlib.util.spec_from_file_location("import_content", MODULE_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
import_content = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = import_content
|
||||
SPEC.loader.exec_module(import_content)
|
||||
|
||||
|
||||
def args_for(**overrides: object) -> argparse.Namespace:
|
||||
values: dict[str, object] = {
|
||||
"type": "page",
|
||||
"title_prefix": None,
|
||||
"date": None,
|
||||
"tags": None,
|
||||
"author": None,
|
||||
"field": [],
|
||||
"collection": None,
|
||||
"overwrite": False,
|
||||
}
|
||||
values.update(overrides)
|
||||
return argparse.Namespace(**values)
|
||||
|
||||
|
||||
class ImportContentTests(unittest.TestCase):
|
||||
def test_author_override_uses_authors_list(self) -> None:
|
||||
doc = import_content.Document(
|
||||
"Title\n\nBody", {"authors": ["Previous Author"]}
|
||||
)
|
||||
|
||||
import_content.apply_schema(
|
||||
[doc], args_for(author="Ada Lovelace")
|
||||
)
|
||||
|
||||
self.assertEqual(doc.meta["authors"], ["Ada Lovelace"])
|
||||
|
||||
def test_duplicate_output_destinations_are_rejected(self) -> None:
|
||||
docs = [
|
||||
import_content.Document("First", {"title": "Same"}),
|
||||
import_content.Document("Second", {"title": "Same"}),
|
||||
]
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
import_content.ContentImportError,
|
||||
"duplicate output destination content/same.md",
|
||||
):
|
||||
import_content.assemble_file_entries(docs, args_for())
|
||||
|
||||
def test_explicit_source_slugs_determine_output_paths(self) -> None:
|
||||
docs = [
|
||||
import_content.Document(
|
||||
"First", {"title": "Same", "slug": "source-one"}
|
||||
),
|
||||
import_content.Document(
|
||||
"Second", {"title": "Same", "slug": "source-two"}
|
||||
),
|
||||
]
|
||||
|
||||
entries = import_content.assemble_file_entries(docs, args_for())
|
||||
|
||||
self.assertEqual(
|
||||
{path.as_posix() for path, _content, _label in entries},
|
||||
{"content/source-one.md", "content/source-two.md"},
|
||||
)
|
||||
|
||||
def test_collections_support_every_content_type(self) -> None:
|
||||
expected = {
|
||||
"essay": (
|
||||
"content/essays/cycle",
|
||||
"/essays/cycle/",
|
||||
),
|
||||
"blog": (
|
||||
"content/blog/cycle",
|
||||
"/blog/cycle/",
|
||||
),
|
||||
"fiction": (
|
||||
"content/fiction/cycle",
|
||||
"/fiction/cycle/",
|
||||
),
|
||||
"poetry": (
|
||||
"content/poetry/cycle",
|
||||
"/poetry/cycle/",
|
||||
),
|
||||
"page": (
|
||||
"content/cycle",
|
||||
"/cycle/",
|
||||
),
|
||||
}
|
||||
|
||||
for content_type, (directory, collection_url) in expected.items():
|
||||
with self.subTest(content_type=content_type):
|
||||
doc = import_content.Document("Body", {"title": "Piece"})
|
||||
entries = import_content.assemble_file_entries(
|
||||
[doc],
|
||||
args_for(type=content_type, collection="Cycle"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{path.as_posix() for path, _content, _label in entries},
|
||||
{f"{directory}/piece.md", f"{directory}/index.md"},
|
||||
)
|
||||
self.assertEqual(
|
||||
doc.meta["collection-url"], collection_url
|
||||
)
|
||||
|
||||
def test_page_collection_rejects_reserved_section_slug(self) -> None:
|
||||
docs = [import_content.Document("Body", {"title": "Piece"})]
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
import_content.ContentImportError,
|
||||
"conflicts with a reserved content directory",
|
||||
):
|
||||
import_content.assemble_file_entries(
|
||||
docs, args_for(type="page", collection="Fiction")
|
||||
)
|
||||
|
||||
def test_writing_types_require_valid_iso_dates(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
import_content.ContentImportError,
|
||||
"a date in YYYY-MM-DD format is required",
|
||||
):
|
||||
import_content.apply_schema(
|
||||
[import_content.Document("Body", {"title": "Story"})],
|
||||
args_for(type="fiction"),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
import_content.ContentImportError, "invalid date"
|
||||
):
|
||||
import_content.apply_schema(
|
||||
[import_content.Document("Body", {"title": "Story"})],
|
||||
args_for(type="fiction", date="2026-02-30"),
|
||||
)
|
||||
|
||||
def test_opening_prose_is_not_consumed_as_a_title(self) -> None:
|
||||
body = "This is the opening sentence.\nThe paragraph continues here."
|
||||
doc = import_content.Document(body)
|
||||
|
||||
import_content.apply_schema([doc], args_for())
|
||||
|
||||
self.assertEqual(doc.meta["title"], "Untitled")
|
||||
self.assertEqual(doc.body, body)
|
||||
self.assertEqual(
|
||||
doc.meta["abstract"],
|
||||
"This is the opening sentence. The paragraph continues here.",
|
||||
)
|
||||
|
||||
def test_invalid_regex_exits_without_writing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "source.txt"
|
||||
source.write_text("Title\n\nBody\n", encoding="utf-8")
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
|
||||
with contextlib.chdir(root), contextlib.redirect_stdout(stdout), \
|
||||
contextlib.redirect_stderr(stderr):
|
||||
status = import_content.main([
|
||||
str(source), "--splitter", "regex", "--regex", "(",
|
||||
])
|
||||
|
||||
self.assertEqual(status, 2)
|
||||
self.assertIn("error: invalid regex pattern", stderr.getvalue())
|
||||
self.assertFalse((root / "content").exists())
|
||||
|
||||
def test_consecutive_headings_keep_the_nonempty_section_title(self) -> None:
|
||||
docs = import_content.split_heading_1(
|
||||
import_content.Document("# First\n# Second\nSecond body"), {}
|
||||
)
|
||||
|
||||
self.assertEqual(len(docs), 1)
|
||||
self.assertEqual(docs[0].meta, {"number": 1, "title": "Second"})
|
||||
self.assertEqual(docs[0].body, "Second body")
|
||||
|
||||
def test_page_break_handles_form_feed_and_preserves_no_match(self) -> None:
|
||||
split = import_content.split_page_break(
|
||||
import_content.Document("first\fsecond"), {}
|
||||
)
|
||||
untouched = import_content.Document("Body only")
|
||||
|
||||
self.assertEqual(
|
||||
[(doc.meta, doc.body) for doc in split],
|
||||
[({"number": 1}, "first"), ({"number": 2}, "second")],
|
||||
)
|
||||
self.assertEqual(
|
||||
import_content.split_page_break(untouched, {}), [untouched]
|
||||
)
|
||||
|
||||
def test_frontmatter_must_be_a_yaml_mapping(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "source.md"
|
||||
source.write_text(
|
||||
"---\nOrdinary paragraph.\n---\nThis survives.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with contextlib.chdir(root), self.assertRaisesRegex(
|
||||
import_content.ContentImportError,
|
||||
"frontmatter must be a YAML mapping",
|
||||
):
|
||||
import_content.read_file_per_document(["source.md"])
|
||||
|
||||
def test_structured_fields_are_validated(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
import_content.ContentImportError,
|
||||
"json entry 0 field 'body' must be a string",
|
||||
):
|
||||
import_content._documents_from_records(
|
||||
[{"title": "Bad", "body": 42}], Path("source.json"), "json"
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
import_content.ContentImportError,
|
||||
"field 'number' must be a positive integer",
|
||||
):
|
||||
import_content.apply_schema(
|
||||
[
|
||||
import_content.Document(
|
||||
"Body", {"title": "Bad", "number": "1"}
|
||||
)
|
||||
],
|
||||
args_for(),
|
||||
)
|
||||
|
||||
def test_collection_index_frontmatter_is_valid_yaml(self) -> None:
|
||||
doc = import_content.Document(
|
||||
"Body",
|
||||
{
|
||||
"title": "Piece",
|
||||
"date": "2026-07-17",
|
||||
"tags": ["poetry"],
|
||||
"poet": "Ada Lovelace",
|
||||
},
|
||||
)
|
||||
|
||||
rendered = import_content.generate_collection_index(
|
||||
[doc], "Essays: 2026"
|
||||
)
|
||||
metadata = import_content.yaml.safe_load(rendered.split("---", 2)[1])
|
||||
|
||||
self.assertEqual(metadata["title"], "Essays: 2026")
|
||||
self.assertEqual(metadata["tags"], ["poetry"])
|
||||
|
||||
def test_abstract_respects_maximum_length_without_spaces(self) -> None:
|
||||
abstract = import_content.auto_abstract("x" * 250, max_chars=200)
|
||||
|
||||
self.assertEqual(len(abstract), 200)
|
||||
self.assertTrue(abstract.endswith(" …"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,960 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
import-content.py — Import content from external sources into the site.
|
||||
|
||||
Produces Markdown files under content/{type}/ from plain text, structured
|
||||
data files, or existing Markdown files.
|
||||
|
||||
Stages:
|
||||
Reader → Splitter → Schema → Writer
|
||||
|
||||
All four stages implemented.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date as date_type, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Document:
|
||||
body: str
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
source_path: Path | None = None
|
||||
|
||||
class ContentImportError(ValueError):
|
||||
"""Invalid source content or an unsupported import request."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reader registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ReaderFn = Callable[..., list[Document]]
|
||||
|
||||
_readers: dict[str, ReaderFn] = {}
|
||||
|
||||
|
||||
def reader(name: str) -> Callable[[ReaderFn], ReaderFn]:
|
||||
def decorate(fn: ReaderFn) -> ReaderFn:
|
||||
_readers[name] = fn
|
||||
return fn
|
||||
return decorate
|
||||
|
||||
|
||||
def get_reader(name: str) -> ReaderFn:
|
||||
fn = _readers.get(name)
|
||||
if fn is None:
|
||||
available = ", ".join(sorted(_readers))
|
||||
print(f"error: unknown reader {name!r} (available: {available})",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in readers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@reader("plain-text")
|
||||
def read_plain_text(source: Path) -> list[Document]:
|
||||
"""Read a single plain-text file as one document."""
|
||||
body = source.read_text(encoding="utf-8", errors="replace")
|
||||
return [Document(body=body, source_path=source)]
|
||||
|
||||
|
||||
_FRONTMATTER_RE = re.compile(
|
||||
r"\A---[ \t]*\r?\n(?P<frontmatter>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|\Z)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
@reader("file-per-document")
|
||||
def read_file_per_document(patterns: list[str]) -> list[Document]:
|
||||
"""Read files matching glob patterns, one document per file.
|
||||
Each file's stem becomes meta['slug'], and frontmatter is parsed
|
||||
when the file starts with delimiter-only `---` lines."""
|
||||
docs: list[Document] = []
|
||||
for pattern in patterns:
|
||||
matched = sorted(Path().glob(pattern))
|
||||
if not matched:
|
||||
print(f"warning: no files matched {pattern!r}", file=sys.stderr)
|
||||
for path in matched:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
meta: dict[str, Any] = {"slug": path.stem}
|
||||
body = text
|
||||
match = _FRONTMATTER_RE.match(text)
|
||||
if match is not None:
|
||||
try:
|
||||
frontmatter = yaml.safe_load(match.group("frontmatter"))
|
||||
except yaml.YAMLError as exc:
|
||||
raise ContentImportError(
|
||||
f"{path}: invalid YAML frontmatter: {exc}"
|
||||
) from exc
|
||||
if frontmatter is None:
|
||||
frontmatter = {}
|
||||
if not isinstance(frontmatter, dict):
|
||||
raise ContentImportError(
|
||||
f"{path}: frontmatter must be a YAML mapping"
|
||||
)
|
||||
meta.update(frontmatter)
|
||||
body = text[match.end():]
|
||||
docs.append(Document(body=body, meta=meta, source_path=path))
|
||||
return docs
|
||||
|
||||
|
||||
def _documents_from_records(
|
||||
raw: Any, source: Path, format_name: str,
|
||||
) -> list[Document]:
|
||||
records = raw if isinstance(raw, list) else [raw]
|
||||
docs: list[Document] = []
|
||||
for index, record in enumerate(records):
|
||||
label = f"{format_name} entry {index}"
|
||||
if not isinstance(record, dict):
|
||||
raise ContentImportError(f"{label} must be a mapping")
|
||||
if not all(isinstance(key, str) for key in record):
|
||||
raise ContentImportError(f"{label} contains a non-string field name")
|
||||
meta = dict(record)
|
||||
body = meta.pop("body", "")
|
||||
if not isinstance(body, str):
|
||||
raise ContentImportError(
|
||||
f"{label} field 'body' must be a string, got "
|
||||
f"{type(body).__name__}"
|
||||
)
|
||||
docs.append(Document(body=body, meta=meta, source_path=source))
|
||||
return docs
|
||||
|
||||
|
||||
@reader("yaml")
|
||||
def read_yaml(source: Path) -> list[Document]:
|
||||
"""Read a YAML file containing one mapping or a list of mappings."""
|
||||
raw = yaml.safe_load(source.read_text(encoding="utf-8"))
|
||||
return _documents_from_records(raw, source, "yaml")
|
||||
|
||||
|
||||
@reader("json")
|
||||
def read_json(source: Path) -> list[Document]:
|
||||
"""Read a JSON file containing one object or a list of objects."""
|
||||
raw = json.loads(source.read_text(encoding="utf-8"))
|
||||
return _documents_from_records(raw, source, "json")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Splitter registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SplitterFn = Callable[[Document, dict[str, Any]], list[Document]]
|
||||
|
||||
_splitters: dict[str, SplitterFn] = {}
|
||||
|
||||
|
||||
def splitter(name: str) -> Callable[[SplitterFn], SplitterFn]:
|
||||
def decorate(fn: SplitterFn) -> SplitterFn:
|
||||
_splitters[name] = fn
|
||||
return fn
|
||||
return decorate
|
||||
|
||||
|
||||
def get_splitter(name: str) -> SplitterFn:
|
||||
fn = _splitters.get(name)
|
||||
if fn is None:
|
||||
available = ", ".join(sorted(_splitters))
|
||||
print(f"error: unknown splitter {name!r} (available: {available})",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in splitters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@splitter("none")
|
||||
def split_none(doc: Document, kwargs: dict[str, Any]) -> list[Document]:
|
||||
"""Pass through — return the document unchanged."""
|
||||
return [doc]
|
||||
|
||||
|
||||
def _heading_title(line: str) -> str | None:
|
||||
title = line.lstrip("# \t")
|
||||
title = title.split(" {#")[0].split("{:")[0].strip()
|
||||
return title or None
|
||||
|
||||
|
||||
@splitter("heading-1")
|
||||
def split_heading_1(doc: Document, kwargs: dict[str, Any]) -> list[Document]:
|
||||
"""Split on Markdown h1 headings (`# Title`). Each heading line becomes
|
||||
the split document's `title`; content before the first heading is kept
|
||||
as a preamble document without a title."""
|
||||
sections: list[tuple[str | None, list[str]]] = []
|
||||
current_title: str | None = None
|
||||
current_lines: list[str] = []
|
||||
found_heading = False
|
||||
|
||||
for line in doc.body.splitlines():
|
||||
if line.startswith("# ") or line.startswith("#\t"):
|
||||
found_heading = True
|
||||
if current_lines:
|
||||
sections.append((current_title, current_lines))
|
||||
current_title = _heading_title(line)
|
||||
current_lines = []
|
||||
else:
|
||||
current_lines.append(line)
|
||||
if current_lines:
|
||||
sections.append((current_title, current_lines))
|
||||
|
||||
if not found_heading:
|
||||
return [doc]
|
||||
|
||||
result: list[Document] = []
|
||||
for title, lines in sections:
|
||||
body = "\n".join(lines).strip()
|
||||
if not body:
|
||||
continue
|
||||
meta = dict(doc.meta)
|
||||
meta["number"] = len(result) + 1
|
||||
if title:
|
||||
meta["title"] = title
|
||||
result.append(Document(body=body, meta=meta, source_path=doc.source_path))
|
||||
|
||||
return result if result else [doc]
|
||||
|
||||
|
||||
def compile_split_regex(pattern: str) -> re.Pattern[str]:
|
||||
if not pattern:
|
||||
raise ContentImportError("regex splitter requires --regex PATTERN")
|
||||
try:
|
||||
matcher = re.compile(pattern, re.MULTILINE)
|
||||
except re.error as exc:
|
||||
raise ContentImportError(
|
||||
f"invalid regex pattern {pattern!r}: {exc}"
|
||||
) from exc
|
||||
if matcher.groups > 0:
|
||||
raise ContentImportError(
|
||||
"regex splitter does not support capture groups "
|
||||
"(use a non-capturing pattern)"
|
||||
)
|
||||
return matcher
|
||||
|
||||
|
||||
@splitter("regex")
|
||||
def split_regex(doc: Document, kwargs: dict[str, Any]) -> list[Document]:
|
||||
"""Split on lines matching a regex pattern (non-capture only). Pass
|
||||
the pattern via `--regex PATTERN`. Matched delimiter lines are
|
||||
removed from the body; each segment between matches becomes a
|
||||
document."""
|
||||
matcher = kwargs.get("matcher")
|
||||
if matcher is None:
|
||||
pattern = kwargs.get("pattern", "")
|
||||
if not isinstance(pattern, str):
|
||||
raise ContentImportError("regex splitter pattern must be a string")
|
||||
matcher = compile_split_regex(pattern)
|
||||
|
||||
parts = matcher.split(doc.body)
|
||||
if len(parts) <= 1:
|
||||
return [doc]
|
||||
|
||||
result: list[Document] = []
|
||||
for segment in parts:
|
||||
body = segment.strip()
|
||||
if not body:
|
||||
continue
|
||||
meta = dict(doc.meta)
|
||||
meta["number"] = len(result) + 1
|
||||
result.append(Document(body=body, meta=meta,
|
||||
source_path=doc.source_path))
|
||||
|
||||
return result if result else [doc]
|
||||
|
||||
|
||||
_PAGE_BREAK_RE = re.compile(
|
||||
r"(?m)^[ \t]*---[ \t]*(?:\r?\n|$)|\f"
|
||||
)
|
||||
|
||||
|
||||
@splitter("page-break")
|
||||
def split_page_break(doc: Document, kwargs: dict[str, Any]) -> list[Document]:
|
||||
"""Split on page-break markers: a line containing only `---` (with
|
||||
optional surrounding whitespace) or a form-feed character (`\\f`).
|
||||
The delimiter line is consumed."""
|
||||
parts = _PAGE_BREAK_RE.split(doc.body)
|
||||
if len(parts) <= 1:
|
||||
return [doc]
|
||||
|
||||
result: list[Document] = []
|
||||
for segment in parts:
|
||||
body = segment.strip()
|
||||
if not body:
|
||||
continue
|
||||
meta = dict(doc.meta)
|
||||
meta["number"] = len(result) + 1
|
||||
result.append(Document(body=body, meta=meta,
|
||||
source_path=doc.source_path))
|
||||
|
||||
return result if result else [doc]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema stage — augment Document.meta with frontmatter fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Default per-type profiles. These set sensible defaults for output
|
||||
# directory, template, and author field name. Overridable via --field.
|
||||
TYPE_PROFILES: dict[str, dict[str, Any]] = {
|
||||
"fiction": {
|
||||
"output_dir": "content/fiction",
|
||||
"author_field": "authors",
|
||||
"body_hard_lines": True,
|
||||
},
|
||||
"essay": {
|
||||
"output_dir": "content/essays",
|
||||
"author_field": "authors",
|
||||
"body_hard_lines": False,
|
||||
},
|
||||
"blog": {
|
||||
"output_dir": "content/blog",
|
||||
"author_field": "authors",
|
||||
"body_hard_lines": False,
|
||||
},
|
||||
"page": {
|
||||
"output_dir": "content",
|
||||
"author_field": "authors",
|
||||
"body_hard_lines": False,
|
||||
},
|
||||
"poetry": {
|
||||
"output_dir": "content/poetry",
|
||||
"author_field": "poet",
|
||||
"body_hard_lines": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
s = text.lower()
|
||||
s = re.sub(r"[^\w\s-]", "", s)
|
||||
s = re.sub(r"[\s_]+", "-", s)
|
||||
s = re.sub(r"-+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
def first_real_line(body: str) -> str:
|
||||
"""First non-empty, non-whitespace line of body."""
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return ""
|
||||
|
||||
|
||||
def auto_abstract(body: str, max_chars: int = 200) -> str:
|
||||
"""Best-effort abstract from the first paragraph."""
|
||||
para: list[str] = []
|
||||
for line in body.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped and para:
|
||||
break
|
||||
if stripped:
|
||||
para.append(stripped)
|
||||
text = " ".join(para)
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
if max_chars < 3:
|
||||
return text[:max_chars]
|
||||
|
||||
suffix = " …"
|
||||
limit = max_chars - len(suffix)
|
||||
cut = text.rfind(" ", 0, limit + 1)
|
||||
if cut <= 0:
|
||||
cut = limit
|
||||
return text[:cut].rstrip() + suffix
|
||||
|
||||
|
||||
WRITING_TYPES = frozenset({"essay", "blog", "fiction", "poetry"})
|
||||
|
||||
|
||||
def _document_label(doc: Document, index: int) -> str:
|
||||
source = f" from {doc.source_path}" if doc.source_path else ""
|
||||
return f"document {index + 1}{source}"
|
||||
|
||||
|
||||
def _validate_document_fields(doc: Document, index: int) -> None:
|
||||
label = _document_label(doc, index)
|
||||
if not isinstance(doc.body, str):
|
||||
raise ContentImportError(
|
||||
f"{label}: body must be a string, got {type(doc.body).__name__}"
|
||||
)
|
||||
|
||||
for key in ("title", "abstract", "slug", "collection", "poet"):
|
||||
value = doc.meta.get(key)
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ContentImportError(
|
||||
f"{label}: field {key!r} must be a string, got "
|
||||
f"{type(value).__name__}"
|
||||
)
|
||||
|
||||
for key in ("authors", "tags"):
|
||||
value = doc.meta.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if (not isinstance(value, list)
|
||||
or not all(isinstance(entry, str) for entry in value)):
|
||||
raise ContentImportError(
|
||||
f"{label}: field {key!r} must be a list of strings"
|
||||
)
|
||||
|
||||
number = doc.meta.get("number")
|
||||
if (number is not None
|
||||
and (not isinstance(number, int) or isinstance(number, bool)
|
||||
or number < 1)):
|
||||
raise ContentImportError(
|
||||
f"{label}: field 'number' must be a positive integer"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_document_date(
|
||||
doc: Document, index: int, required: bool,
|
||||
) -> None:
|
||||
label = _document_label(doc, index)
|
||||
value = doc.meta.get("date")
|
||||
if value is None or value == "":
|
||||
if required:
|
||||
raise ContentImportError(
|
||||
f"{label}: a date in YYYY-MM-DD format is required"
|
||||
)
|
||||
doc.meta.pop("date", None)
|
||||
return
|
||||
|
||||
if isinstance(value, datetime):
|
||||
value = value.date()
|
||||
if isinstance(value, date_type):
|
||||
normalized = value.isoformat()
|
||||
elif isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
else:
|
||||
raise ContentImportError(
|
||||
f"{label}: field 'date' must use YYYY-MM-DD format"
|
||||
)
|
||||
|
||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", normalized):
|
||||
raise ContentImportError(
|
||||
f"{label}: field 'date' must use YYYY-MM-DD format"
|
||||
)
|
||||
try:
|
||||
date_type.fromisoformat(normalized)
|
||||
except ValueError as exc:
|
||||
raise ContentImportError(
|
||||
f"{label}: invalid date {normalized!r}"
|
||||
) from exc
|
||||
doc.meta["date"] = normalized
|
||||
|
||||
|
||||
def _infer_title(body: str) -> tuple[str | None, str]:
|
||||
lines = body.splitlines()
|
||||
first_index = next(
|
||||
(index for index, line in enumerate(lines) if line.strip()), None
|
||||
)
|
||||
if first_index is None:
|
||||
return None, body
|
||||
|
||||
first_line = lines[first_index].strip()
|
||||
title: str | None = None
|
||||
if first_line.startswith("# ") or first_line.startswith("#\t"):
|
||||
title = _heading_title(first_line)
|
||||
else:
|
||||
followed_by_blank = (
|
||||
first_index + 1 < len(lines)
|
||||
and not lines[first_index + 1].strip()
|
||||
)
|
||||
if (followed_by_blank and len(first_line) < 80
|
||||
and not first_line.endswith((".", ",", ";", ":", "!", "?"))):
|
||||
title = first_line
|
||||
|
||||
if title is None:
|
||||
return None, body
|
||||
lines.pop(first_index)
|
||||
return title, "\n".join(lines).strip()
|
||||
|
||||
|
||||
def apply_schema(docs: list[Document], args: argparse.Namespace) -> list[Document]:
|
||||
"""Augment each document's meta with inferred fields and CLI overrides."""
|
||||
profile = TYPE_PROFILES.get(args.type, TYPE_PROFILES["page"])
|
||||
|
||||
for index, doc in enumerate(docs):
|
||||
_validate_document_fields(doc, index)
|
||||
body = doc.body.strip()
|
||||
|
||||
title_prefix = getattr(args, "title_prefix", None) or ""
|
||||
has_title = bool(doc.meta.get("title"))
|
||||
|
||||
if title_prefix and doc.meta.get("number") is not None:
|
||||
doc.meta["title"] = f"{title_prefix} {doc.meta['number']}"
|
||||
elif not has_title:
|
||||
inferred_title, body = _infer_title(body)
|
||||
doc.meta["title"] = inferred_title or "Untitled"
|
||||
|
||||
if "abstract" not in doc.meta:
|
||||
doc.meta["abstract"] = auto_abstract(body)
|
||||
|
||||
if args.date is not None:
|
||||
doc.meta["date"] = args.date
|
||||
|
||||
if args.tags is not None:
|
||||
doc.meta["tags"] = [
|
||||
tag.strip() for tag in args.tags.split(",") if tag.strip()
|
||||
]
|
||||
|
||||
if args.author is not None:
|
||||
author_field = profile["author_field"]
|
||||
doc.meta[author_field] = (
|
||||
args.author if author_field == "poet" else [args.author]
|
||||
)
|
||||
|
||||
if hasattr(args, "field") and args.field:
|
||||
for key_value in args.field:
|
||||
if "=" not in key_value:
|
||||
print(
|
||||
f"warning: --field {key_value!r} is not key=value, "
|
||||
"skipping",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
key, value = key_value.split("=", 1)
|
||||
doc.meta[key] = value
|
||||
|
||||
lines = body.splitlines()
|
||||
content_lines = [line for line in lines if line.strip()]
|
||||
if content_lines:
|
||||
indent = min(
|
||||
len(line) - len(line.lstrip()) for line in content_lines
|
||||
)
|
||||
lines = [
|
||||
line[indent:] if len(line) >= indent else line for line in lines
|
||||
]
|
||||
|
||||
normalized: list[str] = []
|
||||
blank_run = 0
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
blank_run += 1
|
||||
if blank_run <= 2:
|
||||
normalized.append(line)
|
||||
else:
|
||||
blank_run = 0
|
||||
normalized.append(line)
|
||||
doc.body = "\n".join(normalized).strip()
|
||||
|
||||
_validate_document_fields(doc, index)
|
||||
_normalize_document_date(
|
||||
doc, index, required=args.type in WRITING_TYPES,
|
||||
)
|
||||
|
||||
return docs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Writer stage — generate Markdown files with YAML frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
INTERNAL_FIELDS = frozenset({
|
||||
"source_path", "delimiter",
|
||||
})
|
||||
|
||||
|
||||
def yaml_frontmatter(meta: dict[str, Any]) -> str:
|
||||
"""Render meta dict as YAML frontmatter. Strips internal fields,
|
||||
uses block scalar (`|`) for multi-line strings."""
|
||||
cleaned = {k: v for k, v in meta.items() if k not in INTERNAL_FIELDS}
|
||||
# Use yaml.dump with block style; sort_keys=False preserves insertion
|
||||
# order so title/date come first.
|
||||
raw = yaml.dump(cleaned, default_flow_style=False,
|
||||
allow_unicode=True, sort_keys=False)
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def slugify_path(title: str, number: int | None = None) -> str:
|
||||
"""Derive a filesystem-safe slug from a title, optionally prefixed
|
||||
with a zero-padded number for ordering."""
|
||||
base = slugify(title)
|
||||
if number is not None:
|
||||
base = f"{number:04d}-{base}"
|
||||
return base or "untitled"
|
||||
|
||||
def document_slug(doc: Document) -> str:
|
||||
title = doc.meta.get("title", "Untitled")
|
||||
slug_source = doc.meta.get("slug") or title
|
||||
return slugify_path(slug_source, doc.meta.get("number"))
|
||||
|
||||
|
||||
def detect_collection_slug(
|
||||
docs: list[Document], cli_collection: str | None,
|
||||
) -> str | None:
|
||||
"""Determine one non-empty collection slug from CLI or document metadata."""
|
||||
values = (
|
||||
[cli_collection]
|
||||
if cli_collection
|
||||
else [
|
||||
doc.meta["collection"]
|
||||
for doc in docs
|
||||
if doc.meta.get("collection")
|
||||
]
|
||||
)
|
||||
if not values:
|
||||
return None
|
||||
|
||||
slugs = {slugify(value) for value in values}
|
||||
if "" in slugs:
|
||||
raise ContentImportError("collection name must contain a letter or number")
|
||||
if len(slugs) > 1:
|
||||
raise ContentImportError(
|
||||
"all documents in one import must use the same collection"
|
||||
)
|
||||
return slugs.pop()
|
||||
|
||||
|
||||
def generate_collection_index(
|
||||
docs: list[Document], collection_name: str,
|
||||
) -> str:
|
||||
"""Generate a collection index.md with links to each document."""
|
||||
entries: list[str] = []
|
||||
for doc in sorted(docs, key=lambda item: item.meta.get("number") or 0):
|
||||
title = doc.meta.get("title", "Untitled")
|
||||
link_title = (
|
||||
title.replace("\\", "\\\\")
|
||||
.replace("[", "\\[")
|
||||
.replace("]", "\\]")
|
||||
)
|
||||
abstract = " ".join(doc.meta.get("abstract", "").split())
|
||||
abstract_line = f" · {abstract[:120]}" if abstract else ""
|
||||
entries.append(
|
||||
f"- [{link_title}]({document_slug(doc)}.html){abstract_line}"
|
||||
)
|
||||
|
||||
first = docs[0].meta if docs else {}
|
||||
index_meta: dict[str, Any] = {
|
||||
"title": collection_name,
|
||||
"abstract": f"{len(docs)} piece{'s' if len(docs) != 1 else ''}",
|
||||
}
|
||||
if first.get("date"):
|
||||
index_meta["date"] = first["date"]
|
||||
if first.get("tags"):
|
||||
index_meta["tags"] = first["tags"]
|
||||
|
||||
authors = first.get("authors")
|
||||
if isinstance(authors, list):
|
||||
author = ", ".join(authors)
|
||||
else:
|
||||
author = first.get("poet", "")
|
||||
details: list[str] = []
|
||||
if author:
|
||||
details.append(f"*{author}*")
|
||||
if first.get("date"):
|
||||
details.append(str(first["date"]))
|
||||
|
||||
detail_line = " · ".join(details)
|
||||
body_parts = [part for part in (detail_line, "\n".join(entries)) if part]
|
||||
body = "\n\n".join(body_parts)
|
||||
return f"---\n{yaml_frontmatter(index_meta)}\n---\n\n{body}\n"
|
||||
|
||||
|
||||
FileEntry = tuple[Path, str, str]
|
||||
|
||||
|
||||
RESERVED_PAGE_COLLECTION_SLUGS = frozenset({
|
||||
"blog",
|
||||
"cv",
|
||||
"drafts",
|
||||
"essays",
|
||||
"fiction",
|
||||
"me",
|
||||
"memento-mori",
|
||||
"music",
|
||||
"photography",
|
||||
"poetry",
|
||||
"scripts",
|
||||
"tag-meta",
|
||||
})
|
||||
|
||||
|
||||
def validate_collection_request(
|
||||
docs: list[Document], args: argparse.Namespace,
|
||||
) -> None:
|
||||
cli_collection = getattr(args, "collection", None)
|
||||
collection_slug = detect_collection_slug(docs, cli_collection)
|
||||
if (collection_slug and args.type == "page"
|
||||
and collection_slug in RESERVED_PAGE_COLLECTION_SLUGS):
|
||||
raise ContentImportError(
|
||||
f"page collection slug {collection_slug!r} conflicts with a "
|
||||
"reserved content directory"
|
||||
)
|
||||
|
||||
|
||||
def assemble_file_entries(
|
||||
docs: list[Document], args: argparse.Namespace,
|
||||
) -> list[FileEntry]:
|
||||
validate_collection_request(docs, args)
|
||||
profile = TYPE_PROFILES.get(args.type, TYPE_PROFILES["page"])
|
||||
base_dir = Path(profile["output_dir"])
|
||||
cli_collection = getattr(args, "collection", None)
|
||||
collection_slug = detect_collection_slug(docs, cli_collection)
|
||||
|
||||
if collection_slug:
|
||||
section_path = profile["output_dir"].removeprefix("content").strip("/")
|
||||
collection_url = "/" + "/".join(
|
||||
part for part in (section_path, collection_slug) if part
|
||||
) + "/"
|
||||
for doc in docs:
|
||||
if cli_collection:
|
||||
doc.meta["collection"] = collection_slug
|
||||
else:
|
||||
doc.meta.setdefault("collection", collection_slug)
|
||||
doc.meta["collection-url"] = collection_url
|
||||
|
||||
entries: list[FileEntry] = []
|
||||
destinations: dict[Path, str] = {}
|
||||
|
||||
def add_entry(path: Path, content: str, label: str) -> None:
|
||||
previous = destinations.get(path)
|
||||
if previous is not None:
|
||||
raise ContentImportError(
|
||||
f"duplicate output destination {path}: {previous} and {label}"
|
||||
)
|
||||
destinations[path] = label
|
||||
entries.append((path, content, label))
|
||||
|
||||
output_dir = base_dir / collection_slug if collection_slug else base_dir
|
||||
for index, doc in enumerate(docs):
|
||||
title = doc.meta.get("title", "Untitled")
|
||||
path = output_dir / f"{document_slug(doc)}.md"
|
||||
frontmatter = yaml_frontmatter(doc.meta)
|
||||
content = f"---\n{frontmatter}\n---\n\n{doc.body}\n"
|
||||
add_entry(path, content, f"document {index + 1} ({title})")
|
||||
|
||||
if collection_slug and docs:
|
||||
collection_name = cli_collection or next(
|
||||
(
|
||||
doc.meta["collection"]
|
||||
for doc in docs
|
||||
if doc.meta.get("collection")
|
||||
),
|
||||
collection_slug,
|
||||
)
|
||||
index_path = base_dir / collection_slug / "index.md"
|
||||
index_content = generate_collection_index(docs, collection_name)
|
||||
add_entry(index_path, index_content, "collection index")
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def write_docs(
|
||||
docs: list[Document],
|
||||
args: argparse.Namespace,
|
||||
file_entries: list[FileEntry] | None = None,
|
||||
) -> int:
|
||||
"""Write documents as Markdown files with YAML frontmatter."""
|
||||
entries = file_entries if file_entries is not None else assemble_file_entries(
|
||||
docs, args
|
||||
)
|
||||
written = 0
|
||||
for path, content, _label in sorted(entries, key=lambda entry: entry[0]):
|
||||
if path.exists() and not args.overwrite:
|
||||
print(f" skip {path.relative_to(Path())}")
|
||||
continue
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
print(f" write {path.relative_to(Path())}")
|
||||
written += 1
|
||||
return written
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Import content from external sources into the site.")
|
||||
parser.add_argument("source", nargs="*",
|
||||
help="Source path(s) or glob pattern(s)")
|
||||
parser.add_argument("--reader", default="plain-text",
|
||||
help="Input format reader (default: plain-text)")
|
||||
parser.add_argument("--list-readers", action="store_true",
|
||||
help="List available readers and exit")
|
||||
parser.add_argument("--splitter", default="none",
|
||||
help="Content splitting strategy (default: none)")
|
||||
parser.add_argument("--regex",
|
||||
help="Regex pattern for the 'regex' splitter")
|
||||
parser.add_argument("--list-splitters", action="store_true",
|
||||
help="List available splitters and exit")
|
||||
parser.add_argument("--type", default="page",
|
||||
choices=sorted(TYPE_PROFILES),
|
||||
help="Content type (default: page)")
|
||||
parser.add_argument("--date",
|
||||
help="Publication date (ISO format, e.g. 2026-07-17)")
|
||||
parser.add_argument("--tags",
|
||||
help="Comma-separated tags (e.g. 'fiction,short-story')")
|
||||
parser.add_argument("--author",
|
||||
help="Author name (maps to 'poet' for poetry, 'authors' for others)")
|
||||
parser.add_argument("--field", action="append", default=[],
|
||||
help="Arbitrary frontmatter field (repeatable, e.g. --field key=val)")
|
||||
parser.add_argument("--title-prefix",
|
||||
help="Prefix for numbered titles (e.g. 'Chapter', 'Sonnet')")
|
||||
parser.add_argument(
|
||||
"--collection",
|
||||
help="Collection name (groups documents under a directory with index)",
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Show what would be written; write nothing")
|
||||
parser.add_argument("--overwrite", action="store_true",
|
||||
help="Overwrite existing files")
|
||||
parser.add_argument("--dump", action="store_true",
|
||||
help="Print parsed documents for debugging")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.list_readers:
|
||||
print("Available readers:")
|
||||
for name in sorted(_readers):
|
||||
doc = (_readers[name].__doc__ or "").strip().split("\n")[0]
|
||||
print(f" {name:22s} {doc}")
|
||||
return 0
|
||||
|
||||
if args.list_splitters:
|
||||
print("Available splitters:")
|
||||
for name in sorted(_splitters):
|
||||
doc = (_splitters[name].__doc__ or "").strip().split("\n")[0]
|
||||
print(f" {name:22s} {doc}")
|
||||
return 0
|
||||
|
||||
if not args.source:
|
||||
print(
|
||||
"error: source argument is required "
|
||||
"(use --list-readers or --list-splitters to see available options)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
reader_fn = get_reader(args.reader)
|
||||
try:
|
||||
if args.reader == "file-per-document":
|
||||
docs = reader_fn(args.source)
|
||||
else:
|
||||
if len(args.source) > 1:
|
||||
print(
|
||||
f"error: {args.reader} reader expects a single source path",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
source = Path(args.source[0])
|
||||
if not source.exists():
|
||||
print(f"error: source not found: {source}", file=sys.stderr)
|
||||
return 2
|
||||
docs = reader_fn(source)
|
||||
except (
|
||||
ContentImportError,
|
||||
json.JSONDecodeError,
|
||||
OSError,
|
||||
yaml.YAMLError,
|
||||
) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not docs:
|
||||
print("error: reader produced no documents", file=sys.stderr)
|
||||
return 2
|
||||
print(f"[reader] {len(docs)} document(s) from {args.reader}", file=sys.stderr)
|
||||
|
||||
splitter_fn = get_splitter(args.splitter)
|
||||
splitter_kwargs: dict[str, Any] = {}
|
||||
try:
|
||||
if args.splitter == "regex":
|
||||
splitter_kwargs["matcher"] = compile_split_regex(args.regex or "")
|
||||
|
||||
split_docs: list[Document] = []
|
||||
for doc in docs:
|
||||
split_docs.extend(splitter_fn(doc, splitter_kwargs))
|
||||
except ContentImportError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(
|
||||
f"[splitter] {len(split_docs)} document(s) after {args.splitter}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
try:
|
||||
schema_docs = apply_schema(split_docs, args)
|
||||
validate_collection_request(schema_docs, args)
|
||||
file_entries = assemble_file_entries(schema_docs, args)
|
||||
except ContentImportError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(
|
||||
f"[schema] {len(schema_docs)} document(s) after schema "
|
||||
f"(type={args.type})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if args.dump:
|
||||
for index, doc in enumerate(schema_docs):
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Document {index}")
|
||||
print(f" source: {doc.source_path}")
|
||||
print(f" meta: {json.dumps(doc.meta, indent=2, default=str)}")
|
||||
preview = doc.body[:300].rstrip()
|
||||
print(f" body ({len(doc.body)} chars, preview):")
|
||||
for line in preview.splitlines()[:10]:
|
||||
print(f" {line}")
|
||||
if len(doc.body) > 300:
|
||||
print(f" … ({len(doc.body) - 300} more chars)")
|
||||
|
||||
if args.dry_run:
|
||||
print(
|
||||
f"\n[DRY RUN] Would process {len(schema_docs)} document(s):",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for path, _content, label in sorted(
|
||||
file_entries, key=lambda entry: entry[0]
|
||||
):
|
||||
print(f" {path} ← {label}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
written = write_docs(schema_docs, args, file_entries)
|
||||
except OSError as exc:
|
||||
print(f"error: could not write output: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if written:
|
||||
print(f"\n{written} file(s) written to content/", file=sys.stderr)
|
||||
print(
|
||||
"Next: review the generated files, then make clean && make build",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"\nNothing written (all files exist; use --overwrite to replace)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||