diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 836cac3fc..10eca74d4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "homepage": "https://github.com/alirezarezvani/claude-skills", "repository": "https://github.com/alirezarezvani/claude-skills", "metadata": { - "description": "380 production-ready skills across 20 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 706 Python tools, 823 reference guides, 114 agents (cs-* + personas), 138 slash commands across 96 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.", + "description": "387 production-ready skills across 20 domains (engineering, engineering-core, marketing, product, c-level, c-level-agents, compliance-os, project management, RA/QM, business growth, finance, productivity, marketing top-level, research, research-ops, business-operations, commercial, markdown-html, loop-library, plus standards). 727 Python tools, 842 reference guides, 117 agents (cs-* + personas), 149 slash commands across 98 marketplace plugins. v2.11.2 vendors engineering/skillopt-sleep — a verbatim copy of microsoft/SkillOpt's stdlib-only skillopt_sleep engine + Claude Code plugin surface, giving a local agent a nightly gated self-improvement cycle (read-only session harvest -> mine -> offline replay -> held-out-gated CLAUDE.md/SKILL.md edits -> staged for explicit /skillopt-sleep adopt). productivity/fable-goal (unreleased, post-v2.11.1) converts a rambling description of a desired outcome into one polished /goal prompt for a fresh autonomous session. v2.11.1 turns product-team and project-management into agent-harness domains: fork-orchestrators with deterministic goal routers, a Jira MCP snapshot bridge (Kanban flow metrics + Monte Carlo forecasting), a delegation-governance loop gate, a continuous-discovery cadence tracker, and an Opportunity Solution Tree linter, with /cs:pm and /cs:product command families. v2.10.3 completes the markdown-html domain with md-slides — slide-deck converter (arrow-key / Space / PgDn / Home/End / P keyboard navigation + presenter mode with split-view clock + speaker notes + next-slide preview + URL-hash deep linking like #3 for direct slide jumps + @media print page-per-slide for browser-native PDF export). Reuses md-document's markdown parser; vanilla JS only (no framework runtime); Prism.js opt-in via --syntax. Joins md-review (v2.10.2 code-review converter), md-document (v2.10.1 long-form converter), and the v2.10.0 foundation (orchestrator + design-system). Compatible with Claude Code, Codex CLI, Gemini CLI, Cursor, OpenClaw, Hermes Agent, Mistral Vibe, and 5 more coding agents.", "version": "2.12.0" }, "plugins": [ @@ -1285,6 +1285,28 @@ ], "category": "marketing" }, + { + "name": "linkedin", + "source": "./marketing/linkedin", + "description": "Organic LinkedIn presence, end to end, with LinkedIn's own rules enforced in code. Orchestrator (context: fork) gates every request against User Agreement §8.2 — refusing automation, scraping, engagement pods, bulk DMs, fake identity and fabricated proof, each with a compliant substitute — then routes to profile / strategy / content / engagement / analytics. Headline and whole-profile scoring with fixes ranked by points per hour; positioning brief with a mandatory exclusion list; cadence priced against real hours with a 90-minute floor; newsletter eligibility + six-month sustainability gate; post linter blocking on engagement bait and screen-reader-hostile Unicode pseudo-bold; repurposing with a reuse ledger; capped commenting roster and template-refusing outreach; analytics that test patterns against a seeded permutation null and refuse to conclude below 10 posts. 17 stdlib tools, 15 references with per-claim confidence levels. No credentials, no API calls, nothing auto-sent. Answers discussion #934.", + "version": "2.12.0", + "author": { + "name": "Alireza Rezvani" + }, + "keywords": [ + "linkedin", + "personal-brand", + "organic-growth", + "content-strategy", + "profile-optimization", + "outreach", + "newsletter", + "social-media", + "thought-leadership", + "career-change" + ], + "category": "marketing" + }, { "name": "pulse", "source": "./research/pulse", @@ -2028,6 +2050,27 @@ "engineering" ], "category": "development" + }, + { + "name": "deep-learning-book", + "source": "./engineering/deep-learning-book", + "description": "Study companion for the Deep Learning textbook by Goodfellow, Bengio & Courville (MIT Press, 2016), free to read at deeplearningbook.org. Twenty chapter files, a glossary, patterns and a cheatsheet index the whole book, and a delta reference dates it against 2026 practice with per-claim confidence levels: double descent qualifying the U-curve, AdamW splitting weight decay from L2, transformers displacing Chapter 10's recurrence, diffusion growing out of Chapter 18's score matching. Four stdlib tools make it executable — a prerequisite-closed reading-path planner that refuses goals the book does not cover, a training diagnostic running Chapter 11's rules in priority order so a NaN is never reported as overfitting, a capacity planner that ranks 'shrink the model' last when overparameterized, and a parameter/FLOP/activation-memory calculator that refuses a stack whose shapes do not connect. A companion, not a compilation: the book is copyrighted, so nothing here reproduces its text.", + "version": "2.12.0", + "author": { + "name": "Alireza Rezvani" + }, + "keywords": [ + "deep-learning", + "machine-learning", + "study-companion", + "goodfellow", + "neural-networks", + "training-diagnostics", + "optimization", + "generative-models", + "engineering" + ], + "category": "development" } ] } diff --git a/.codex/skills-index.json b/.codex/skills-index.json index 9024d5015..54db69f5d 100644 --- a/.codex/skills-index.json +++ b/.codex/skills-index.json @@ -3,7 +3,7 @@ "name": "claude-code-skills", "description": "Production-ready skill packages for AI agents - Marketing, Engineering, Product, C-Level, PM, and RA/QM", "repository": "https://github.com/alirezarezvani/claude-skills", - "total_skills": 356, + "total_skills": 362, "skills": [ { "name": "agent-launcher-orchestrator", @@ -1225,15 +1225,15 @@ }, { "name": "run", - "source": "../../engineering/agenthub/skills/run", + "source": "../../engineering/autoresearch-agent/skills/run", "category": "engineering-advanced", - "description": "One-shot lifecycle command that chains init \u2192 baseline \u2192 spawn \u2192 eval \u2192 merge in a single invocation. Use when the user runs /hub:run or asks to execute a full AgentHub competition end-to-end." + "description": "Run a single experiment iteration. Edit the target file, evaluate, keep or discard. Use when the user runs /ar:run or asks for one manual autoresearch iteration." }, { "name": "run", - "source": "../../engineering/autoresearch-agent/skills/run", + "source": "../../engineering/agenthub/skills/run", "category": "engineering-advanced", - "description": "Run a single experiment iteration. Edit the target file, evaluate, keep or discard. Use when the user runs /ar:run or asks for one manual autoresearch iteration." + "description": "One-shot lifecycle command that chains init \u2192 baseline \u2192 spawn \u2192 eval \u2192 merge in a single invocation. Use when the user runs /hub:run or asks to execute a full AgentHub competition end-to-end." }, { "name": "runbook-generator", @@ -1541,6 +1541,42 @@ "category": "marketing", "description": "When the user wants to plan a product launch, feature announcement, or release strategy. Also use when the user mentions 'launch,' 'Product Hunt,' 'feature release,' 'announcement,' 'go-to-market,' 'beta launch,' 'early access,' 'waitlist,' 'product update,' 'GTM plan,' 'launch checklist,' or 'launch momentum.' This skill covers phased launches, channel strategy, and ongoing launch momentum." }, + { + "name": "linkedin-analytics", + "source": "../../marketing/linkedin/skills/linkedin-analytics", + "category": "marketing", + "description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts." + }, + { + "name": "linkedin-content", + "source": "../../marketing/linkedin/skills/linkedin-content", + "category": "marketing", + "description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger." + }, + { + "name": "linkedin-engagement", + "source": "../../marketing/linkedin/skills/linkedin-engagement", + "category": "marketing", + "description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent." + }, + { + "name": "linkedin-profile", + "source": "../../marketing/linkedin/skills/linkedin-profile", + "category": "marketing", + "description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold." + }, + { + "name": "linkedin-skills", + "source": "../../marketing/linkedin/skills/linkedin-skills", + "category": "marketing", + "description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts." + }, + { + "name": "linkedin-strategy", + "source": "../../marketing/linkedin/skills/linkedin-strategy", + "category": "marketing", + "description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment." + }, { "name": "local-seo-manager", "source": "../../marketing-skill/skills/local-seo-manager", @@ -2194,7 +2230,7 @@ "description": "Financial analysis, valuation, and forecasting skills" }, "marketing": { - "count": 50, + "count": 56, "source": "../../marketing-skill", "description": "Marketing, content, and demand generation skills" }, diff --git a/.codex/skills/linkedin-analytics b/.codex/skills/linkedin-analytics new file mode 120000 index 000000000..48c585ec8 --- /dev/null +++ b/.codex/skills/linkedin-analytics @@ -0,0 +1 @@ +../../marketing/linkedin/skills/linkedin-analytics \ No newline at end of file diff --git a/.codex/skills/linkedin-content b/.codex/skills/linkedin-content new file mode 120000 index 000000000..2d20cd3f6 --- /dev/null +++ b/.codex/skills/linkedin-content @@ -0,0 +1 @@ +../../marketing/linkedin/skills/linkedin-content \ No newline at end of file diff --git a/.codex/skills/linkedin-engagement b/.codex/skills/linkedin-engagement new file mode 120000 index 000000000..8a0108200 --- /dev/null +++ b/.codex/skills/linkedin-engagement @@ -0,0 +1 @@ +../../marketing/linkedin/skills/linkedin-engagement \ No newline at end of file diff --git a/.codex/skills/linkedin-profile b/.codex/skills/linkedin-profile new file mode 120000 index 000000000..9a37fb873 --- /dev/null +++ b/.codex/skills/linkedin-profile @@ -0,0 +1 @@ +../../marketing/linkedin/skills/linkedin-profile \ No newline at end of file diff --git a/.codex/skills/linkedin-skills b/.codex/skills/linkedin-skills new file mode 120000 index 000000000..cfa524e3f --- /dev/null +++ b/.codex/skills/linkedin-skills @@ -0,0 +1 @@ +../../marketing/linkedin/skills/linkedin-skills \ No newline at end of file diff --git a/.codex/skills/linkedin-strategy b/.codex/skills/linkedin-strategy new file mode 120000 index 000000000..94e681399 --- /dev/null +++ b/.codex/skills/linkedin-strategy @@ -0,0 +1 @@ +../../marketing/linkedin/skills/linkedin-strategy \ No newline at end of file diff --git a/.gemini/skills-index.json b/.gemini/skills-index.json index fa911e2a7..3d3d3dbaa 100644 --- a/.gemini/skills-index.json +++ b/.gemini/skills-index.json @@ -1,7 +1,7 @@ { "version": "1.0.0", "name": "gemini-cli-skills", - "total_skills": 430, + "total_skills": 436, "skills": [ { "name": "README", @@ -1793,6 +1793,36 @@ "category": "marketing-top-level", "description": "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides." }, + { + "name": "linkedin-analytics", + "category": "marketing-top-level", + "description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts." + }, + { + "name": "linkedin-content", + "category": "marketing-top-level", + "description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger." + }, + { + "name": "linkedin-engagement", + "category": "marketing-top-level", + "description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent." + }, + { + "name": "linkedin-profile", + "category": "marketing-top-level", + "description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold." + }, + { + "name": "linkedin-skills", + "category": "marketing-top-level", + "description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts." + }, + { + "name": "linkedin-strategy", + "category": "marketing-top-level", + "description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment." + }, { "name": "agile-product-owner", "category": "product", @@ -2208,7 +2238,7 @@ "description": "Marketing resources" }, "marketing-top-level": { - "count": 1, + "count": 7, "description": "Marketing-top-level resources" }, "product": { diff --git a/.gemini/skills/linkedin-analytics/SKILL.md b/.gemini/skills/linkedin-analytics/SKILL.md new file mode 120000 index 000000000..75c386a85 --- /dev/null +++ b/.gemini/skills/linkedin-analytics/SKILL.md @@ -0,0 +1 @@ +../../../marketing/linkedin/skills/linkedin-analytics/SKILL.md \ No newline at end of file diff --git a/.gemini/skills/linkedin-content/SKILL.md b/.gemini/skills/linkedin-content/SKILL.md new file mode 120000 index 000000000..407416377 --- /dev/null +++ b/.gemini/skills/linkedin-content/SKILL.md @@ -0,0 +1 @@ +../../../marketing/linkedin/skills/linkedin-content/SKILL.md \ No newline at end of file diff --git a/.gemini/skills/linkedin-engagement/SKILL.md b/.gemini/skills/linkedin-engagement/SKILL.md new file mode 120000 index 000000000..eabb64d52 --- /dev/null +++ b/.gemini/skills/linkedin-engagement/SKILL.md @@ -0,0 +1 @@ +../../../marketing/linkedin/skills/linkedin-engagement/SKILL.md \ No newline at end of file diff --git a/.gemini/skills/linkedin-profile/SKILL.md b/.gemini/skills/linkedin-profile/SKILL.md new file mode 120000 index 000000000..a7511039a --- /dev/null +++ b/.gemini/skills/linkedin-profile/SKILL.md @@ -0,0 +1 @@ +../../../marketing/linkedin/skills/linkedin-profile/SKILL.md \ No newline at end of file diff --git a/.gemini/skills/linkedin-skills/SKILL.md b/.gemini/skills/linkedin-skills/SKILL.md new file mode 120000 index 000000000..568a56308 --- /dev/null +++ b/.gemini/skills/linkedin-skills/SKILL.md @@ -0,0 +1 @@ +../../../marketing/linkedin/skills/linkedin-skills/SKILL.md \ No newline at end of file diff --git a/.gemini/skills/linkedin-strategy/SKILL.md b/.gemini/skills/linkedin-strategy/SKILL.md new file mode 120000 index 000000000..abc19c5cc --- /dev/null +++ b/.gemini/skills/linkedin-strategy/SKILL.md @@ -0,0 +1 @@ +../../../marketing/linkedin/skills/linkedin-strategy/SKILL.md \ No newline at end of file diff --git a/.hermes/skills/claude-skills/engineering/agent-memory b/.hermes/skills/claude-skills/engineering/agent-memory new file mode 120000 index 000000000..abf3f5958 --- /dev/null +++ b/.hermes/skills/claude-skills/engineering/agent-memory @@ -0,0 +1 @@ +../../../../engineering/agent-memory/skills/agent-memory \ No newline at end of file diff --git a/.hermes/skills/claude-skills/engineering/hivemind b/.hermes/skills/claude-skills/engineering/hivemind new file mode 120000 index 000000000..963a58262 --- /dev/null +++ b/.hermes/skills/claude-skills/engineering/hivemind @@ -0,0 +1 @@ +../../../../engineering/hivemind \ No newline at end of file diff --git a/.hermes/skills/claude-skills/engineering/skill-doctor b/.hermes/skills/claude-skills/engineering/skill-doctor new file mode 120000 index 000000000..121b44524 --- /dev/null +++ b/.hermes/skills/claude-skills/engineering/skill-doctor @@ -0,0 +1 @@ +../../../../engineering/skill-doctor/skills/skill-doctor \ No newline at end of file diff --git a/.hermes/skills/claude-skills/marketing/linkedin-analytics b/.hermes/skills/claude-skills/marketing/linkedin-analytics new file mode 120000 index 000000000..ed1b50559 --- /dev/null +++ b/.hermes/skills/claude-skills/marketing/linkedin-analytics @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-analytics \ No newline at end of file diff --git a/.hermes/skills/claude-skills/marketing/linkedin-content b/.hermes/skills/claude-skills/marketing/linkedin-content new file mode 120000 index 000000000..bf2abf225 --- /dev/null +++ b/.hermes/skills/claude-skills/marketing/linkedin-content @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-content \ No newline at end of file diff --git a/.hermes/skills/claude-skills/marketing/linkedin-engagement b/.hermes/skills/claude-skills/marketing/linkedin-engagement new file mode 120000 index 000000000..fc83c49ad --- /dev/null +++ b/.hermes/skills/claude-skills/marketing/linkedin-engagement @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-engagement \ No newline at end of file diff --git a/.hermes/skills/claude-skills/marketing/linkedin-profile b/.hermes/skills/claude-skills/marketing/linkedin-profile new file mode 120000 index 000000000..fd83dbe1e --- /dev/null +++ b/.hermes/skills/claude-skills/marketing/linkedin-profile @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-profile \ No newline at end of file diff --git a/.hermes/skills/claude-skills/marketing/linkedin-skills b/.hermes/skills/claude-skills/marketing/linkedin-skills new file mode 120000 index 000000000..04127b9fd --- /dev/null +++ b/.hermes/skills/claude-skills/marketing/linkedin-skills @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-skills \ No newline at end of file diff --git a/.hermes/skills/claude-skills/marketing/linkedin-strategy b/.hermes/skills/claude-skills/marketing/linkedin-strategy new file mode 120000 index 000000000..edbd09489 --- /dev/null +++ b/.hermes/skills/claude-skills/marketing/linkedin-strategy @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-strategy \ No newline at end of file diff --git a/.hermes/skills/claude-skills/skills-index.json b/.hermes/skills/claude-skills/skills-index.json index d3486b707..2d741a7a2 100644 --- a/.hermes/skills/claude-skills/skills-index.json +++ b/.hermes/skills/claude-skills/skills-index.json @@ -1,6 +1,6 @@ { "source": "claude-code-skills", - "total_skills": 353, + "total_skills": 362, "domains": { "engineering": [ { @@ -198,6 +198,11 @@ "description": "Turn any domain folder of skills into a bounded agentic loop: compile a goal into a verifiable task plan, execute tasks with the domain's own tools, verify every task with machine-run checks, retry with caps, escalate to a human when budgets exhaust, and refuse to close until everything is verified or explicitly waived. Use when you want an agent or subagent to pick up a goal and drive it to a verified close across one of this repo's 18 domains ('run this goal through the engineering harness', 'set up an agentic loop for marketing work', 'make the finance domain self-verifying'). NOT for authoring Claude Code Workflow-tool .js scripts (workflow-builder), N-agent tournaments on one task (agenthub), single-file metric optimization (autoresearch-agent), or discovering published loop recipes (loop-library).", "path": "engineering/agent-harness" }, + { + "name": "agent-memory", + "description": "Use when a project's CLAUDE.md has grown past what anyone reads and you want the agent to learn durable facts from its own sessions instead \u2014 or when asking why the agent keeps re-learning the same correction, why a remembered rule is wrong, or where a memory line came from. Implements a four-tier store (L0 transcripts / L1 candidates / L2 project context / L3 stable persona) where promotion is earned by recurrence across sessions and days, never by one confident statement, and nothing reaches a committed file without a human adopting it.", + "path": "engineering/agent-memory" + }, { "name": "agenthub", "description": "Multi-agent collaboration plugin that spawns N parallel subagents competing on the same task via git worktree isolation. Agents work independently, results are evaluated by metric or LLM judge, and the best branch is merged. Use when: user wants multiple approaches tried in parallel \u2014 code optimization, content variation, research exploration, or any task that benefits from parallel competition. Requires: a git repo.", @@ -348,6 +353,11 @@ "description": "Helm chart development agent skill and plugin for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw \u2014 chart scaffolding, values design, template patterns, dependency management, security hardening, and chart testing. Use when: user wants to create or improve Helm charts, design values.yaml files, implement template helpers, audit chart security (RBAC, network policies, pod security), manage subcharts, or run helm lint/test.", "path": "engineering/helm-chart-builder" }, + { + "name": "hivemind", + "description": "Orchestrate free opencode workers from Claude Code to cut token costs. Use when delegating grunt work to a single worker or a parallel swarm (scout/coder/tester) with worktree isolation, benchmarking against opencode, or when the user says \"spawn a worker\", \"swarm\", \"delegate to opencode\", or \"/oc\".", + "path": "engineering/hivemind" + }, { "name": "human-gate", "description": "Runs the human-verification lane of an agent loop, and proves review happened before work is called done. Builds a single-file HTML review page, collects batched feedback as a structured artifact instead of chat prose, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has reviewed at all. Use when a plan, spec, RFC, report, landing page, migration, or any irreversible action needs human sign-off before shipping, or on requests such as 'get sign-off', 'have someone check this', 'hold until reviewed', 'needs approval first'. NOT for making AI text sound human (use content-humanizer or behuman). NOT for reviewing code diffs (use md-review or code-reviewer).", @@ -393,6 +403,11 @@ "description": "PreToolUse security-anti-pattern hook for Claude Code. Catches 12 common security risks (command injection, XSS, SQL injection, unsafe deserialization, GitHub Actions workflow injection, eval/new Function code injection) BEFORE the Edit/Write/MultiEdit operation completes. Session-state caching prevents duplicate warnings on the same file+rule combo. Stdlib only \u2014 no dependencies. Use when you want a safety net during Claude Code sessions that touch security-sensitive code (auth, payments, user input handling, IaC). Disable with ENABLE_SECURITY_REMINDER=0 if you need to perform a verified-safe operation that would otherwise trip a pattern. Triggers \u2014 \"add security hook\", \"block unsafe code\", \"detect command injection before write\", \"prevent SQL injection patterns\", \"security warning hook\".", "path": "engineering/security-guidance" }, + { + "name": "skill-doctor", + "description": "Use when the user wants their agent setup graded from real conversation history, asks which installed skills are actually working, or wants evidence-backed skill edits \u2014 scores recent local Claude Code / Codex sessions against efficiency and code-quality rubrics, then drafts skill changes gated by a deterministic aggregator and renders one local shareable report.", + "path": "engineering/skill-doctor" + }, { "name": "skillopt-sleep", "description": "Use when the user wants their Claude agent to self-improve from past usage, asks about a nightly/offline 'sleep' or 'dream' cycle, memory/skill consolidation, or says things like 'make my agent better the more I use it', 'review my past sessions', 'learn my preferences', 'consolidate what you learned', 'run the sleep cycle', or wants to schedule offline self-optimization. Drives the skillopt_sleep engine: harvest past sessions -> mine recurring tasks -> replay offline -> consolidate validated CLAUDE.md and SKILL.md behind a held-out gate.", @@ -1537,6 +1552,36 @@ "name": "landing", "description": "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides.", "path": "marketing/landing" + }, + { + "name": "linkedin-analytics", + "description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts.", + "path": "marketing/linkedin-analytics" + }, + { + "name": "linkedin-content", + "description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger.", + "path": "marketing/linkedin-content" + }, + { + "name": "linkedin-engagement", + "description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent.", + "path": "marketing/linkedin-engagement" + }, + { + "name": "linkedin-profile", + "description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold.", + "path": "marketing/linkedin-profile" + }, + { + "name": "linkedin-skills", + "description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts.", + "path": "marketing/linkedin-skills" + }, + { + "name": "linkedin-strategy", + "description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment.", + "path": "marketing/linkedin-strategy" } ], "research": [ diff --git a/.vibe/skills/claude-skills/engineering/agent-memory b/.vibe/skills/claude-skills/engineering/agent-memory new file mode 120000 index 000000000..abf3f5958 --- /dev/null +++ b/.vibe/skills/claude-skills/engineering/agent-memory @@ -0,0 +1 @@ +../../../../engineering/agent-memory/skills/agent-memory \ No newline at end of file diff --git a/.vibe/skills/claude-skills/engineering/hivemind b/.vibe/skills/claude-skills/engineering/hivemind new file mode 120000 index 000000000..963a58262 --- /dev/null +++ b/.vibe/skills/claude-skills/engineering/hivemind @@ -0,0 +1 @@ +../../../../engineering/hivemind \ No newline at end of file diff --git a/.vibe/skills/claude-skills/engineering/skill-doctor b/.vibe/skills/claude-skills/engineering/skill-doctor new file mode 120000 index 000000000..121b44524 --- /dev/null +++ b/.vibe/skills/claude-skills/engineering/skill-doctor @@ -0,0 +1 @@ +../../../../engineering/skill-doctor/skills/skill-doctor \ No newline at end of file diff --git a/.vibe/skills/claude-skills/marketing/linkedin-analytics b/.vibe/skills/claude-skills/marketing/linkedin-analytics new file mode 120000 index 000000000..ed1b50559 --- /dev/null +++ b/.vibe/skills/claude-skills/marketing/linkedin-analytics @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-analytics \ No newline at end of file diff --git a/.vibe/skills/claude-skills/marketing/linkedin-content b/.vibe/skills/claude-skills/marketing/linkedin-content new file mode 120000 index 000000000..bf2abf225 --- /dev/null +++ b/.vibe/skills/claude-skills/marketing/linkedin-content @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-content \ No newline at end of file diff --git a/.vibe/skills/claude-skills/marketing/linkedin-engagement b/.vibe/skills/claude-skills/marketing/linkedin-engagement new file mode 120000 index 000000000..fc83c49ad --- /dev/null +++ b/.vibe/skills/claude-skills/marketing/linkedin-engagement @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-engagement \ No newline at end of file diff --git a/.vibe/skills/claude-skills/marketing/linkedin-profile b/.vibe/skills/claude-skills/marketing/linkedin-profile new file mode 120000 index 000000000..fd83dbe1e --- /dev/null +++ b/.vibe/skills/claude-skills/marketing/linkedin-profile @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-profile \ No newline at end of file diff --git a/.vibe/skills/claude-skills/marketing/linkedin-skills b/.vibe/skills/claude-skills/marketing/linkedin-skills new file mode 120000 index 000000000..04127b9fd --- /dev/null +++ b/.vibe/skills/claude-skills/marketing/linkedin-skills @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-skills \ No newline at end of file diff --git a/.vibe/skills/claude-skills/marketing/linkedin-strategy b/.vibe/skills/claude-skills/marketing/linkedin-strategy new file mode 120000 index 000000000..edbd09489 --- /dev/null +++ b/.vibe/skills/claude-skills/marketing/linkedin-strategy @@ -0,0 +1 @@ +../../../../marketing/linkedin/skills/linkedin-strategy \ No newline at end of file diff --git a/.vibe/skills/claude-skills/skills-index.json b/.vibe/skills/claude-skills/skills-index.json index a23f2efd1..a590a7baf 100644 --- a/.vibe/skills/claude-skills/skills-index.json +++ b/.vibe/skills/claude-skills/skills-index.json @@ -1,7 +1,7 @@ { "source": "claude-code-skills", "layout": "nested", - "total_skills": 353, + "total_skills": 362, "domains": { "engineering": [ { @@ -199,6 +199,11 @@ "description": "Turn any domain folder of skills into a bounded agentic loop: compile a goal into a verifiable task plan, execute tasks with the domain's own tools, verify every task with machine-run checks, retry with caps, escalate to a human when budgets exhaust, and refuse to close until everything is verified or explicitly waived. Use when you want an agent or subagent to pick up a goal and drive it to a verified close across one of this repo's 18 domains ('run this goal through the engineering harness', 'set up an agentic loop for marketing work', 'make the finance domain self-verifying'). NOT for authoring Claude Code Workflow-tool .js scripts (workflow-builder), N-agent tournaments on one task (agenthub), single-file metric optimization (autoresearch-agent), or discovering published loop recipes (loop-library).", "path": "engineering/agent-harness" }, + { + "name": "agent-memory", + "description": "Use when a project's CLAUDE.md has grown past what anyone reads and you want the agent to learn durable facts from its own sessions instead \u2014 or when asking why the agent keeps re-learning the same correction, why a remembered rule is wrong, or where a memory line came from. Implements a four-tier store (L0 transcripts / L1 candidates / L2 project context / L3 stable persona) where promotion is earned by recurrence across sessions and days, never by one confident statement, and nothing reaches a committed file without a human adopting it.", + "path": "engineering/agent-memory" + }, { "name": "agenthub", "description": "Multi-agent collaboration plugin that spawns N parallel subagents competing on the same task via git worktree isolation. Agents work independently, results are evaluated by metric or LLM judge, and the best branch is merged. Use when: user wants multiple approaches tried in parallel \u2014 code optimization, content variation, research exploration, or any task that benefits from parallel competition. Requires: a git repo.", @@ -349,6 +354,11 @@ "description": "Helm chart development agent skill and plugin for Claude Code, Codex, Gemini CLI, Cursor, OpenClaw \u2014 chart scaffolding, values design, template patterns, dependency management, security hardening, and chart testing. Use when: user wants to create or improve Helm charts, design values.yaml files, implement template helpers, audit chart security (RBAC, network policies, pod security), manage subcharts, or run helm lint/test.", "path": "engineering/helm-chart-builder" }, + { + "name": "hivemind", + "description": "Orchestrate free opencode workers from Claude Code to cut token costs. Use when delegating grunt work to a single worker or a parallel swarm (scout/coder/tester) with worktree isolation, benchmarking against opencode, or when the user says \"spawn a worker\", \"swarm\", \"delegate to opencode\", or \"/oc\".", + "path": "engineering/hivemind" + }, { "name": "human-gate", "description": "Runs the human-verification lane of an agent loop, and proves review happened before work is called done. Builds a single-file HTML review page, collects batched feedback as a structured artifact instead of chat prose, and runs a gate that refuses to close while a BLOCKER is open, the reviewer is unnamed, or nobody has reviewed at all. Use when a plan, spec, RFC, report, landing page, migration, or any irreversible action needs human sign-off before shipping, or on requests such as 'get sign-off', 'have someone check this', 'hold until reviewed', 'needs approval first'. NOT for making AI text sound human (use content-humanizer or behuman). NOT for reviewing code diffs (use md-review or code-reviewer).", @@ -394,6 +404,11 @@ "description": "PreToolUse security-anti-pattern hook for Claude Code. Catches 12 common security risks (command injection, XSS, SQL injection, unsafe deserialization, GitHub Actions workflow injection, eval/new Function code injection) BEFORE the Edit/Write/MultiEdit operation completes. Session-state caching prevents duplicate warnings on the same file+rule combo. Stdlib only \u2014 no dependencies. Use when you want a safety net during Claude Code sessions that touch security-sensitive code (auth, payments, user input handling, IaC). Disable with ENABLE_SECURITY_REMINDER=0 if you need to perform a verified-safe operation that would otherwise trip a pattern. Triggers \u2014 \"add security hook\", \"block unsafe code\", \"detect command injection before write\", \"prevent SQL injection patterns\", \"security warning hook\".", "path": "engineering/security-guidance" }, + { + "name": "skill-doctor", + "description": "Use when the user wants their agent setup graded from real conversation history, asks which installed skills are actually working, or wants evidence-backed skill edits \u2014 scores recent local Claude Code / Codex sessions against efficiency and code-quality rubrics, then drafts skill changes gated by a deterministic aggregator and renders one local shareable report.", + "path": "engineering/skill-doctor" + }, { "name": "skillopt-sleep", "description": "Use when the user wants their Claude agent to self-improve from past usage, asks about a nightly/offline 'sleep' or 'dream' cycle, memory/skill consolidation, or says things like 'make my agent better the more I use it', 'review my past sessions', 'learn my preferences', 'consolidate what you learned', 'run the sleep cycle', or wants to schedule offline self-optimization. Drives the skillopt_sleep engine: harvest past sessions -> mine recurring tasks -> replay offline -> consolidate validated CLAUDE.md and SKILL.md behind a held-out gate.", @@ -1538,6 +1553,36 @@ "name": "landing", "description": "Generates a premium single-page HTML landing page with 3D CSS animations, GSAP scroll effects, and mouse-parallax depth. Forcing intake (product + elevator pitch, audience register, brand overrides, tone) locks down positioning before any copy or markup is written, so the page reflects the actual product rather than generic boilerplate. Use whenever the user says 'landing for X', 'create a landing page', 'build a landing page', 'make a landing page for X', 'I need a web page for Y', or provides product/service details and wants a polished website. Also triggers on 'promotional page', 'product page', 'one-pager', 'web presence', 'sales page'. Outputs a single self-contained HTML file (Claude Code) or HTML artifact (Claude.ai). Supports configurable brand colors via CSS custom property overrides.", "path": "marketing/landing" + }, + { + "name": "linkedin-analytics", + "description": "Use when someone wants to understand their own LinkedIn numbers \u2014 which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on \"why did my reach drop\", \"what's working on my LinkedIn\", \"analyze my posts\", \"do carousels do better for me\", \"should I test this\", \"LinkedIn analytics\". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment \u2014 refusing to conclude anything below 10 posts.", + "path": "marketing/linkedin-analytics" + }, + { + "name": "linkedin-content", + "description": "Use when someone wants to write, edit, or lint a LinkedIn post \u2014 a story, how-to, opinion piece, carousel script, video script, or poll \u2014 or wants an article, talk, or transcript repurposed into posts. Triggers on \"write a LinkedIn post\", \"is this hook any good\", \"review my post\", \"turn this into LinkedIn posts\", \"carousel\", \"what format should this be\". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger.", + "path": "marketing/linkedin-content" + }, + { + "name": "linkedin-engagement", + "description": "Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn \u2014 a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on \"who should I engage with\", \"write a connection request\", \"cold DM\", \"LinkedIn outreach\", \"networking strategy\", \"how many invites can I send\". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent.", + "path": "marketing/linkedin-engagement" + }, + { + "name": "linkedin-profile", + "description": "Use when someone wants their LinkedIn profile audited or rewritten \u2014 headline, About section, experience bullets, Featured, banner, recommendations \u2014 or says \"fix my headline\", \"my profile gets views but nothing happens\", \"optimize my LinkedIn profile\", \"what should my About section say\". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the \"\u2026see more\" fold.", + "path": "marketing/linkedin-profile" + }, + { + "name": "linkedin-skills", + "description": "Use when someone wants to grow an organic LinkedIn presence \u2014 a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on \"grow my LinkedIn\", \"fix my headline\", \"write a LinkedIn post\", \"what should I post about\", \"LinkedIn strategy\", \"connection request\", \"my reach dropped\". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts.", + "path": "marketing/linkedin-skills" + }, + { + "name": "linkedin-strategy", + "description": "Use when someone needs a LinkedIn plan rather than a post \u2014 content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on \"what should I post about\", \"how often should I post\", \"LinkedIn content strategy\", \"should I start a LinkedIn newsletter\", \"grow my following\", \"90-day plan\". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment.", + "path": "marketing/linkedin-strategy" } ], "research": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3448c138b..09ab4a7ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,106 @@ All notable changes to the Claude Skills Library will be documented in this file The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added — engineering/deep-learning-book: a companion to the free Deep Learning textbook + +New `engineering/deep-learning-book/` plugin: a study companion for *Deep Learning* by +Goodfellow, Bengio & Courville (MIT Press, 2016), free to read at deeplearningbook.org. +One skill, 4 stdlib-only tools, 4 references, 3 assets, 1 agent, 3 commands. + +- **Companion, not compilation — and that was the design decision.** `book-to-skill`'s + rights gate refuses a `shareable` package without `public-domain` / `open-license` / + `internal-docs` / `author-permission`, none of which applies to an MIT Press title whose + own site states the HTML-only format exists as a friction against copying under the + authors' contract; its rights reference lists publishing a compiled skill of a copyrighted + book to a public marketplace under **Do not**, and its hard rule 1 forbids scraping a book + from the web. So nothing here reproduces the book: every chapter file is original + synthesis linking to the official free chapter, and the organizing structure is the + published table of contents. **The rule this sets:** convert a copyrighted work into a + companion that indexes and updates the source, never a compilation that reproduces it. +- **The compiled-skill shape, validated by the compiler's own gate.** Master `SKILL.md` + (~2.0k tokens, chapter index + topic index), `chapters/ch01..ch20`, `glossary.md`, + `patterns.md`, `cheatsheet.md` — passes `book_skill_validator.py` clean with every file + inside `token_budget_estimator.py`'s caps. +- **The 2016→2026 delta layer is the differentiator.** A compilation freezes a source at its + publication date; this one dates it. Every chapter carries "What changed after 2016", and + `references/book_to_2026_delta.md` gives five corrections with primary citations and + per-claim confidence: double descent qualifying Ch 5's U-curve, AdamW splitting weight + decay from L2, transformers displacing Ch 10's recurrence, diffusion growing out of Ch 18's + score matching, and self-supervised learning vindicating Ch 15 while replacing its methods. + Two contested claims are marked contested rather than propagated; two named as folklore. + Stated rule: **the conflict is almost always in the recommendation, not the analysis.** +- **Four tools, each with a real refusal.** `reading_path_planner.py` (prerequisite closure + over the book's actual dependency graph, priced in weeks; exit 3 naming what covers an + out-of-scope goal, exit 4 with forcing questions when unroutable; ties break on keyword + specificity, not alphabetically); `training_diagnostics.py` (Ch 11's rules in priority + order, so a NaN is never reported as overfitting; exit 4 rather than diagnosing with no + instruments); `capacity_planner.py` (regularization ladder in cost order with "shrink the + model" ranked **last** in the overparameterized regime; exit 4 on a val-below-train split); + `model_arithmetic.py` (params/FLOPs/activation memory for conv, linear, position-wise + linear, MHA and LSTM/GRU stacks; exit 5 naming the layer whose shapes do not connect). +- `cs-deep-learning-tutor` agent; `/cs:deep-learning`, `/cs:dl-reading-path`, + `/cs:dl-diagnose`. **Counters:** skills 386 → 387; tools 723 → 727; refs 838 → 842; + agents 116 → 117; commands 146 → 149; plugins 97 → 98. + +### Added — marketing/linkedin: organic LinkedIn presence with the platform rules in code + +New `marketing/linkedin/` plugin, answering +[discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934), which +asked for a strategic assistant for growing a LinkedIn presence organically rather than a +post generator. Six skills, 17 stdlib-only tools, 15 references, 2 agents, 8 commands. + +- **The design constraint is the differentiator.** The plugin holds no LinkedIn credentials, + makes no API calls, scrapes nothing, and sends nothing — automated posting, connecting, + commenting, and liking are prohibited by LinkedIn's User Agreement §8.2, and a restricted + account ends a compounding asset. `linkedin_policy_gate.py` runs before any drafting and + refuses seven classes of request (automation, scraping, engagement pods, bulk messaging, + fake identity, fabricated proof, named third-party automation platforms), each with the + policy anchor and a **compliant substitute** — the gate never just says no. +- **`linkedin-skills`** (orchestrator, `context: fork`) — policy gate + deterministic + five-lane router (route 0 / ask 2 / no-signal 3) with cross-lane prerequisites. +- **`linkedin-profile`** — headline scored on audience/outcome/proof/searchability/clarity + against the 220-char cap and the ~60-char front-load window; whole-profile audit across 14 + weighted checks with fixes ranked by **points per hour** and a first-hour plan; About + builder that refuses a fold cutting mid-sentence or carrying no audience and no proof. +- **`linkedin-strategy`** — positioning brief validator (six real objectives, an audience + specific enough to exclude someone, 2-4 proof-backed pillars, a **mandatory exclusion + list**); cadence planner that prices the week in minutes and returns a comment-only plan + below a 90-minute floor; newsletter gate on LinkedIn's published 150-follower evaluation + threshold plus six-month cadence cost, with a stop rule written before issue one. +- **`linkedin-content`** — post linter across mechanics / hook / integrity / accessibility, + blocking on the 3,000-char cap, engagement bait, and **Unicode pseudo-bold** (screen + readers announce it as mathematical symbols; search does not index it as words); format + picker over nine native formats; repurpose splitter with a **content-hash reuse ledger**. +- **`linkedin-engagement`** — comment roster capped at two appearances per account per week; + message builder that refuses a template without a person-specific line and refuses an ask + in a first-touch connection note; volume guard that refuses above 40 invitations a day as + an automation plan regardless of intent. +- **`linkedin-analytics`** — median/MAD describer with Tukey bands (a mean describes a + distribution none of your posts belong to); four-gate permutation pattern miner with + **multiple-comparisons accounting** and mirrored-candidate de-duplication; experiment + planner that reports infeasibility rather than quietly shrinking the effect. Refuses to + conclude anything below 10 posts. +- **Evidence discipline — two widely repeated claims corrected rather than propagated.** + (1) "A personalised connection note triples acceptance (~45% vs ~15%)" is not supported by + the largest samples, which show acceptance close to identical either way (~26.4%); what a + note moves is the **post-accept reply rate** (~5.4% → ~9.4%), which is why the builder + refuses an ask in a first-touch note. (2) The ~19% in-body link reach reduction has never + been confirmed by LinkedIn as a penalty and has a plausible dwell-time explanation, so it + is a warning rather than a blocking finding. Every reference carries per-claim confidence + levels (🟢 LinkedIn-official / 🟡 third-party study / 🔴 folklore). +- All six SKILL.md files are a full **6/6 PASS** on the write-a-skill checklist. Every tool + supports `--help`, `--sample`, and `--output json` with typed exit codes. +- **Counters:** skills 380 → 386; plugins 96 → 97; tools 706 → 723; references 823 → 838; + agents 114 → 116; commands 138 → 146 (verified via `scripts/derive_counters.py --check`). + +### Fixed + +- Synced three previously-merged skills (`engineering/agent-memory`, `engineering/hivemind`, + `engineering/skill-doctor`) into the `.hermes/` and `.vibe/` mirror trees, which had + drifted behind `.codex/` and `.gemini/`. + ## [2.12.0] - 2026-08-24 — consolidated release: 20 domains, 380 skills, full issue-triage sweep **First tagged release since v2.9.0.** Versions 2.10.0–2.11.2 were documented in diff --git a/CLAUDE.md b/CLAUDE.md index 4e1787531..9bb7613d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co This is a **comprehensive skills library** for Claude AI and Claude Code - reusable, production-ready skill packages that bundle domain expertise, best practices, analysis tools, and strategic frameworks. The repository provides modular skills that teams can download and use directly in their workflows. -**Current Scope:** 380 production-ready skills across 20 domains with 706 Python automation tools, 823 reference guides, 114 agents (cs-* + 7 personas), and 138 slash commands, distributed as 96 marketplace plugins. Headline counters are derived from the tree by `scripts/derive_counters.py` (run with `--check` to verify the docs still match). **v2.11.2 (current)** vendors **engineering/skillopt-sleep/** — started as a verbatim, byte-for-byte copy of `microsoft/SkillOpt`'s `skillopt_sleep` engine (stdlib-only, zero third-party deps) and its Claude Code plugin surface (`skills/`, `hooks/`, `commands/`, `scripts/`), then received 23 targeted patches after ten rounds of adversarial review (see `engineering/skillopt-sleep/README.md`'s numbered "Deviations from upstream" list, the authoritative source — re-apply all 23 on re-vendor). Gives a local agent a nightly "sleep cycle": read-only harvest of past Claude Code session transcripts → mine recurring tasks → replay offline on the user's own API budget → consolidate into `CLAUDE.md`/`SKILL.md` edits behind a held-out validation gate → stage for review; nothing live changes until an explicit `/skillopt-sleep adopt` (which backs up first). Default `mock` backend spends no API budget. The heavier `skillopt` *training* package (benchmark-driven, needs `numpy`/`openai`/`azure-*` + hand-labeled train/val/test data per task) was deliberately **not** vendored — it optimizes one narrow, scoreable task at a time, which doesn't fit this repo's broad domain-expertise skills or its no-ML-in-scripts/no-test-framework conventions; `skillopt_sleep` mines its "benchmark" from real usage instead, which does fit. Attribution preserved in `.claude-plugin/authoring-notes.json` + `LICENSE` + `README.md` (MIT, © Microsoft Corporation / Yifan Yang), following the same verbatim-vendor pattern as `loop-library/`. **Unreleased (post-v2.11.2, PR #961 merged)** adds the **agent-launcher/** top-level domain — a plugin re-implementation of Anthropic's `launch-your-agent` reference skill (Apache-2.0; independent, not a fork) for building **Claude Managed Agents (CMA)** in the user's own account. Every session starts with a goal (`./my-agent/goal.json`, surfaced by an opt-in `AGENT_LAUNCHER_SESSION=1` SessionStart hook + `/cs:goal`); `loop_compiler.py` compiles that goal into a **bounded grade→iterate loop** (CMA `user.define_outcome` self-grading, `max_iterations` 1..20), a **recurring POSIX-cron scheduled-deployment loop**, or a **single-pass interview→stage→launch workflow**. 6 skills (orchestrator `context: fork` + interview + stage-launch + grade-iterate + run-without-you + wrap-up), 18 stdlib-only deterministic scaffolder tools (NO network/API calls — live launches emitted as BYOK curl that never prints the key), 4 agents, 8 `/cs:*` commands, opt-in hooks, 5 shared references, 4 assets; validators enforce CMA limits (≤20 skills/session, ≤8 memory stores, depth-1 multiagent, `max_iterations` ≤20, ≤1000 deployments/org). Distinct from `engineering/agent-harness` (generic bounded loop over any domain) and `engineering/write-a-skill` (authors Claude Code skills, not CMAs). **Unreleased (post-v2.11.2)** ships the **productivity coverage expansion** — public audit record `audit/productivity-2026-07/` (all 7 legacy skills scored, 24/24 scripts smoke-tested, coverage map vs the personal-productivity canon) + 3 gap-filling plugins, each with a cs-* agent, /cs:* commands, 3 stdlib scripts and 3 cited references: **weekly-review** (GTD loop; review-gate refuses COMPLETE while a mandatory GET CURRENT step is missing), **deep-work** (time-block planner refusing >4h deep demand, shallow-work budget auditor, focus-session logger), **meetings** (MEET/ASYNC/NOT-READY cost gate, outcome-required agenda builder, action-item extractor with ORPHAN/NO-DUE flags). **Unreleased (post-v2.11.1)** added **productivity/fable-goal** — converts a rambling description of a desired outcome into one polished, copy-paste `/goal` prompt for a fresh autonomous session (ported from `duncan-buildroom/freeskills`). **v2.11.1 (complete)** upgrades **product-team/** and **project-management/** into agent-harness domains: both prose routers rebuilt as `context: fork` orchestrators with deterministic goal routers (exit-code route/ask/refuse), a Jira MCP snapshot bridge (Kanban-Guide-2025 flow metrics + seeded Monte Carlo forecasts, verified end-to-end into velocity_analyzer), a delegation-governance loop gate (human owner / reviewer / machine-checkable acceptance / close refusal), a Torres continuous-discovery cadence tracker + Opportunity Solution Tree linter, cs-pm-orchestrator + cs-product-orchestrator agents, and /cs:pm|grill-pm|pm-loop + /cs:product|grill-product|product-loop commands — plus the public audit record `audit/pm-product-agentic-2026-07/` (AR-rubric scores for all 26 skills, research-backed improvement fields, executable verification criteria). **v2.9.0 (complete)** added the **research-ops/** top-level domain — enterprise Research Operations (orchestrator + clinical-research + research-finance + market-research + product-research), the managed counterpart to the academic research/ domain, with `context: fork` orchestration and a Matt Pocock "Forcing-question library" in every SKILL.md plus `/cs:grill-research-ops`. **v2.8.0 (complete)** added 2 new top-level domains — **business-operations/** (7 internal-ops skills: orchestrator + process-mapper + vendor-management + capacity-planner + internal-comms + knowledge-ops + procurement-optimizer) and **commercial/** (8 per-deal-economics skills: orchestrator + pricing-strategist + deal-desk + partnerships-architect + channel-economics + commercial-policy + rfp-responder + commercial-forecaster) — with orchestrator skills using `context: fork` for chaining, Matt Pocock docs-anchored "Forcing-question library" in every SKILL.md, plus `/cs:grill-bizops` and `/cs:grill-commercial`. **v2.8.2** adds a productivity-shaped `handoff` skill (sibling to engineering/handoff) inspired by Matt Pocock — first-run setup with configurable save location, redaction linter, SessionStart + SessionEnd hooks, fidelity self-check, `--refresh` flag. **v2.8.1** upgraded the engineering role-skills (senior-fullstack / senior-frontend / senior-backend) with karpathy-coder + Matt Pocock decision engines + per-role forcing questions. v2.7.3 ports `alirezarezvani/aeo-box` — AEO (Answer Engine Optimization) skill into marketing-skill/ + security-guidance PreToolUse hook into engineering/. v2.7.0 added 13 Path-B skills across 3 top-level domains (productivity, marketing, research). v2.6.0 added 4 Matt Pocock-derived productivity skills. +**Current Scope:** 387 production-ready skills across 20 domains with 727 Python automation tools, 842 reference guides, 117 agents (cs-* + 7 personas), and 149 slash commands, distributed as 98 marketplace plugins. Headline counters are derived from the tree by `scripts/derive_counters.py` (run with `--check` to verify the docs still match). **v2.11.2 (current)** vendors **engineering/skillopt-sleep/** — started as a verbatim, byte-for-byte copy of `microsoft/SkillOpt`'s `skillopt_sleep` engine (stdlib-only, zero third-party deps) and its Claude Code plugin surface (`skills/`, `hooks/`, `commands/`, `scripts/`), then received 23 targeted patches after ten rounds of adversarial review (see `engineering/skillopt-sleep/README.md`'s numbered "Deviations from upstream" list, the authoritative source — re-apply all 23 on re-vendor). Gives a local agent a nightly "sleep cycle": read-only harvest of past Claude Code session transcripts → mine recurring tasks → replay offline on the user's own API budget → consolidate into `CLAUDE.md`/`SKILL.md` edits behind a held-out validation gate → stage for review; nothing live changes until an explicit `/skillopt-sleep adopt` (which backs up first). Default `mock` backend spends no API budget. The heavier `skillopt` *training* package (benchmark-driven, needs `numpy`/`openai`/`azure-*` + hand-labeled train/val/test data per task) was deliberately **not** vendored — it optimizes one narrow, scoreable task at a time, which doesn't fit this repo's broad domain-expertise skills or its no-ML-in-scripts/no-test-framework conventions; `skillopt_sleep` mines its "benchmark" from real usage instead, which does fit. Attribution preserved in `.claude-plugin/authoring-notes.json` + `LICENSE` + `README.md` (MIT, © Microsoft Corporation / Yifan Yang), following the same verbatim-vendor pattern as `loop-library/`. **Unreleased (post-v2.11.2, PR #961 merged)** adds the **agent-launcher/** top-level domain — a plugin re-implementation of Anthropic's `launch-your-agent` reference skill (Apache-2.0; independent, not a fork) for building **Claude Managed Agents (CMA)** in the user's own account. Every session starts with a goal (`./my-agent/goal.json`, surfaced by an opt-in `AGENT_LAUNCHER_SESSION=1` SessionStart hook + `/cs:goal`); `loop_compiler.py` compiles that goal into a **bounded grade→iterate loop** (CMA `user.define_outcome` self-grading, `max_iterations` 1..20), a **recurring POSIX-cron scheduled-deployment loop**, or a **single-pass interview→stage→launch workflow**. 6 skills (orchestrator `context: fork` + interview + stage-launch + grade-iterate + run-without-you + wrap-up), 18 stdlib-only deterministic scaffolder tools (NO network/API calls — live launches emitted as BYOK curl that never prints the key), 4 agents, 8 `/cs:*` commands, opt-in hooks, 5 shared references, 4 assets; validators enforce CMA limits (≤20 skills/session, ≤8 memory stores, depth-1 multiagent, `max_iterations` ≤20, ≤1000 deployments/org). Distinct from `engineering/agent-harness` (generic bounded loop over any domain) and `engineering/write-a-skill` (authors Claude Code skills, not CMAs). **Unreleased (post-v2.11.2)** ships the **productivity coverage expansion** — public audit record `audit/productivity-2026-07/` (all 7 legacy skills scored, 24/24 scripts smoke-tested, coverage map vs the personal-productivity canon) + 3 gap-filling plugins, each with a cs-* agent, /cs:* commands, 3 stdlib scripts and 3 cited references: **weekly-review** (GTD loop; review-gate refuses COMPLETE while a mandatory GET CURRENT step is missing), **deep-work** (time-block planner refusing >4h deep demand, shallow-work budget auditor, focus-session logger), **meetings** (MEET/ASYNC/NOT-READY cost gate, outcome-required agenda builder, action-item extractor with ORPHAN/NO-DUE flags). **Unreleased (post-v2.11.1)** added **productivity/fable-goal** — converts a rambling description of a desired outcome into one polished, copy-paste `/goal` prompt for a fresh autonomous session (ported from `duncan-buildroom/freeskills`). **v2.11.1 (complete)** upgrades **product-team/** and **project-management/** into agent-harness domains: both prose routers rebuilt as `context: fork` orchestrators with deterministic goal routers (exit-code route/ask/refuse), a Jira MCP snapshot bridge (Kanban-Guide-2025 flow metrics + seeded Monte Carlo forecasts, verified end-to-end into velocity_analyzer), a delegation-governance loop gate (human owner / reviewer / machine-checkable acceptance / close refusal), a Torres continuous-discovery cadence tracker + Opportunity Solution Tree linter, cs-pm-orchestrator + cs-product-orchestrator agents, and /cs:pm|grill-pm|pm-loop + /cs:product|grill-product|product-loop commands — plus the public audit record `audit/pm-product-agentic-2026-07/` (AR-rubric scores for all 26 skills, research-backed improvement fields, executable verification criteria). **v2.9.0 (complete)** added the **research-ops/** top-level domain — enterprise Research Operations (orchestrator + clinical-research + research-finance + market-research + product-research), the managed counterpart to the academic research/ domain, with `context: fork` orchestration and a Matt Pocock "Forcing-question library" in every SKILL.md plus `/cs:grill-research-ops`. **v2.8.0 (complete)** added 2 new top-level domains — **business-operations/** (7 internal-ops skills: orchestrator + process-mapper + vendor-management + capacity-planner + internal-comms + knowledge-ops + procurement-optimizer) and **commercial/** (8 per-deal-economics skills: orchestrator + pricing-strategist + deal-desk + partnerships-architect + channel-economics + commercial-policy + rfp-responder + commercial-forecaster) — with orchestrator skills using `context: fork` for chaining, Matt Pocock docs-anchored "Forcing-question library" in every SKILL.md, plus `/cs:grill-bizops` and `/cs:grill-commercial`. **v2.8.2** adds a productivity-shaped `handoff` skill (sibling to engineering/handoff) inspired by Matt Pocock — first-run setup with configurable save location, redaction linter, SessionStart + SessionEnd hooks, fidelity self-check, `--refresh` flag. **v2.8.1** upgraded the engineering role-skills (senior-fullstack / senior-frontend / senior-backend) with karpathy-coder + Matt Pocock decision engines + per-role forcing questions. v2.7.3 ports `alirezarezvani/aeo-box` — AEO (Answer Engine Optimization) skill into marketing-skill/ + security-guidance PreToolUse hook into engineering/. v2.7.0 added 13 Path-B skills across 3 top-level domains (productivity, marketing, research). v2.6.0 added 4 Matt Pocock-derived productivity skills. **Key Distinction**: This is NOT a traditional application. It's a library of skill packages meant to be extracted and deployed by users into their own Claude workflows. @@ -178,6 +178,67 @@ Vendors `engineering/skillopt-sleep/` — a byte-for-byte start from [microsoft/ --- +**Unreleased (post-v2.12.0) — engineering/deep-learning-book (companion, not compilation):** + +New `engineering/deep-learning-book/` plugin: a study companion for *Deep Learning* by +Goodfellow, Bengio & Courville (MIT Press, 2016), free to read at deeplearningbook.org. +Requested as "convert this book into a skill"; built as a **companion** instead, and the +reasoning is the reusable part. + +- **Why not `book-to-skill`.** That skill's rights gate refuses a `shareable` package without + `public-domain` / `open-license` / `internal-docs` / `author-permission` — none applies to an + MIT Press title whose own site states the HTML-only format exists as a friction against + copying under the authors' contract — and its `rights_and_provenance.md` lists publishing a + compiled skill of a copyrighted book to a public marketplace under **Do not**. Its hard rule 1 + also forbids scraping a book from the web, so the pipeline could not have run against a URL. + **The rule this sets:** when a user asks to convert a copyrighted work into a *shareable* + skill, build a companion that indexes and updates the source, not a compilation that + reproduces it; compile only when the gate clears, and keep the output local when it does not. + Recorded in `skills/deep-learning-book/references/rights_and_use.md`. +- **What it is.** The compiled-skill *shape* (master SKILL.md at ~2.0k tokens with a chapter + index and topic index, `chapters/ch01..ch20`, glossary, patterns, cheatsheet) filled with + original synthesis — no passages, figures, or per-paragraph paraphrase — each chapter file + linking to the official free chapter. Passes `book_to_skill`'s own `book_skill_validator.py` + clean and every file is inside `token_budget_estimator.py`'s caps. +- **The differentiator is the delta layer.** A static compilation freezes a source at its + publication date; this one dates it. Every chapter carries "What changed after 2016", and + `references/book_to_2026_delta.md` gives five corrections with primary citations and per-claim + confidence levels: double descent qualifying Ch 5's U-curve, AdamW splitting weight decay from + L2 (Ch 7 treats them as interchangeable), transformers displacing Ch 10's recurrence (keep the + gradient analysis, drop the architecture advice), diffusion growing out of Ch 14's denoising + autoencoders and Ch 18's score matching, and self-supervised learning vindicating Ch 15 while + replacing every method it names. Two claims are marked contested rather than propagated (batch + norm's "internal covariate shift" mechanism; the strong lottery-ticket form) and two named as + folklore. The stated rule: **the conflict is almost always in the recommendation, not the + analysis.** +- **4 stdlib tools, each with a real refusal.** `reading_path_planner.py` (goal → prerequisite + closure over the book's actual dependency graph, ordered, priced in weeks; exit 3 for a goal + the book does not cover, naming what does; exit 4 with forcing questions when unroutable), + `training_diagnostics.py` (Ch 11's rules in priority order, so a NaN is never reported as + overfitting; exit 4 rather than diagnosing with no instruments), `capacity_planner.py` + (regularization ladder in cost order, with "shrink the model" pushed **last** in the + overparameterized regime per double descent; exit 4 on a val-below-train split), + `model_arithmetic.py` (params/FLOPs/activation memory per example for conv, linear, MHA and + LSTM/GRU stacks; exit 5 naming the layer whose shapes do not connect). +- 4 references citing 7-8 sources each, 3 assets, `cs-deep-learning-tutor` agent, `/cs:deep-learning` + + `/cs:dl-reading-path` + `/cs:dl-diagnose`. **Counters:** skills 386 → 387; tools 723 → 727; + refs 838 → 842; agents 116 → 117; commands 146 → 149; plugins 97 → 98 (verified via + `scripts/derive_counters.py --check`). + +--- + +**Unreleased (post-v2.12.0) — marketing/linkedin (organic LinkedIn presence, platform rules in code):** + +New `marketing/linkedin/` plugin answering [discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934), which asked for a strategic assistant for growing a LinkedIn presence organically rather than a post generator. Six skills (orchestrator + profile + strategy + content + engagement + analytics), 17 stdlib-only tools, 15 references, 2 agents, 8 `/cs:*` commands. + +- **The design constraint is the differentiator.** No LinkedIn credentials, no API calls, no scraping, nothing auto-sent — automated posting/connecting/commenting are prohibited by LinkedIn's User Agreement §8.2, and a restricted account ends a compounding asset. `linkedin_policy_gate.py` runs before any drafting and refuses seven request classes (automation, scraping, engagement pods, bulk messaging, fake identity, fabricated proof, named third-party automation platforms), each carrying the policy anchor **and a compliant substitute** — the gate never just says no. Orchestrator is `context: fork` with a deterministic five-lane router (route 0 / ask 2 / no-signal 3) and cross-lane prerequisites. +- **Refusals are real, not warnings.** Cadence under 90 min/week returns a comment-only plan rather than a schedule that dies in week five; a newsletter whose six-month cost exceeds the budget is refused before the promise is made; an experiment needing more posts than a quarter allows is reported infeasible rather than quietly re-sized; the pattern miner refuses to test anything below 10 posts and reports `NOTHING_SURVIVED` as a finding. +- **Evidence discipline — two popular claims corrected rather than propagated** (the `andreessen` precedent). (1) "A personalised note triples connection acceptance (~45% vs ~15%)" is not supported by the largest samples, which show acceptance close to identical either way (~26.4%); what a note actually moves is the **post-accept reply rate** (~5.4% → ~9.4%) — which is why `outreach_message_builder.py` refuses an ask in a first-touch note. (2) The ~19% in-body external-link reach reduction has **never been confirmed by LinkedIn as a penalty** and has a plausible dwell-time explanation, so it is a warning, not a block. Every reference carries per-claim confidence levels (🟢 LinkedIn-official / 🟡 third-party study / 🔴 folklore, named as folklore). +- **Accessibility is a blocking lint finding, not a footnote.** Unicode pseudo-bold (the output of "bold text generators") fails the linter: screen readers announce those characters as mathematical symbols and LinkedIn search does not index them as words. Engagement bait is also blocking, per the Professional Community Policies. `pattern_miner.py` adds multiple-comparisons accounting and mirrored-candidate de-duplication that no comparable tool implements. +- All six SKILL.md files are a full **6/6 PASS** on the write-a-skill checklist (binding for post-v2.6.0 skills). Every tool supports `--help`/`--sample`/`--output json` with typed exit codes. **Counters:** skills 380 → 386; tools 706 → 723; refs 823 → 838; agents 114 → 116; commands 138 → 146; plugins 96 → 97 (verified via `scripts/derive_counters.py --check`). + +--- + **Unreleased (post-v2.11.2) — engineering/skill-doctor (grade the agent setup from real sessions):** Rebuild of [warpdotdev/common-skills](https://github.com/warpdotdev/common-skills)' `skill-doctor` (MIT, © Denver Technologies, Inc., pinned at `f3b58c81`) as a full plugin. Harvests the last N days of local Claude Code / Codex sessions scoped to one repo, has the agent judge each condensed transcript against two verbatim-preserved rubrics (efficiency, code quality — labels only, closed tables), measures which installed skills actually fired, and proposes only the skill edits the evidence justifies — one local, self-contained HTML report; nothing is ever uploaded. @@ -720,4 +781,4 @@ When I correct you, or you catch yourself making a mistake: before continuing ad **Last Updated:** August 24, 2026 **Version:** v2.12.0 (consolidated release — see CHANGELOG.md) -**Status:** 380 production-ready skills across 20 domains, 96 marketplace plugins, docs site live (counters derived via `scripts/derive_counters.py`) +**Status:** 387 production-ready skills across 20 domains, 98 marketplace plugins, docs site live (counters derived via `scripts/derive_counters.py`) diff --git a/README.md b/README.md index d9f4b9fab..0bf4fd153 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Claude Code Skills & Plugins — Agent Skills for Every Coding Tool -**380 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.** +**387 production-ready Claude Code skills, plugins, and agent skills for 13 AI coding tools.** The most comprehensive open-source library of Claude Code skills and agent plugins — also works with OpenAI Codex, Gemini CLI, Cursor, and 9 more coding agents. Reusable expertise packages covering engineering, DevOps, marketing (incl. AEO — Answer Engine Optimization for LLM citation), security (PreToolUse hooks), compliance, C-level advisory (incl. founder-mode CFO/CMO/CRO/CPO/COO/CHRO/CISO/GC/CDO/CAIO/CCO/VPE personas + 21 /cs:* slash commands), productivity (capture/email/reflect/weekly-review/deep-work/meetings), an academic research stack (litreview/grants/dossier/patent/syllabus/pulse/notebooklm/deep-research + hybrid router), and enterprise Research Operations (clinical-research/research-finance/market-research/product-research, v2.9.0). @@ -10,10 +10,10 @@ The most comprehensive open-source library of Claude Code skills and agent plugi [^vibe]: Mistral Vibe is also **BYO-sync tier**: the repo ships a pre-generated `.vibe/skills/claude-skills/` tree, run `./scripts/vibe-install.sh` once locally to install into `~/.vibe/skills/`. Same agentskills.io SKILL.md standard — no format conversion. Docs: . [![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=for-the-badge)](https://opensource.org/licenses/MIT) -[![Skills](https://img.shields.io/badge/Skills-380-brightgreen?style=for-the-badge)](#skills-overview) -[![Agents](https://img.shields.io/badge/Agents-114-blue?style=for-the-badge)](#agents) +[![Skills](https://img.shields.io/badge/Skills-387-brightgreen?style=for-the-badge)](#skills-overview) +[![Agents](https://img.shields.io/badge/Agents-117-blue?style=for-the-badge)](#agents) [![Personas](https://img.shields.io/badge/Personas-7-purple?style=for-the-badge)](#personas) -[![Commands](https://img.shields.io/badge/Commands-138-orange?style=for-the-badge)](#commands) +[![Commands](https://img.shields.io/badge/Commands-149-orange?style=for-the-badge)](#commands) [![Stars](https://img.shields.io/github/stars/alirezarezvani/claude-skills?style=for-the-badge)](https://github.com/alirezarezvani/claude-skills/stargazers) [![SkillCheck Validated](https://img.shields.io/badge/SkillCheck-Validated-4c1?style=for-the-badge)](https://getskillcheck.com) @@ -29,7 +29,7 @@ Claude Code skills (also called agent skills or coding agent plugins) are modula - **Python tools** — 706 CLI scripts (all stdlib-only, zero pip installs) - **Reference docs** — 823 templates, checklists, and domain-specific knowledge files -**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 706 Python tools run anywhere Python runs. +**One repo, thirteen platforms.** Works natively as Claude Code plugins, Codex agent skills, Gemini CLI skills, Hermes Agent skills, Mistral Vibe skills, and converts to more tools via `scripts/convert.sh`. All 727 Python tools run anywhere Python runs. ### Skills vs Agents vs Personas @@ -152,16 +152,16 @@ Run `./scripts/convert.sh --tool all` to generate tool-specific outputs locally. ## Skills Overview -**380 production-ready skills across 20 domains:** +**387 production-ready skills across 20 domains:** | Domain | Skills | Highlights | Details | |--------|--------|------------|---------| | **🔧 Engineering — Core** | 53 | Architecture, frontend, backend, fullstack, QA, DevOps, SecOps, AI/ML, data, Playwright Pro (test gen, flaky fix, migrations), self-improving agent (auto-memory curation), security suite, a11y audit, **named-persona-adversarial-review** (review via named engineering philosophies), **embedded-iot-mentor** (MCU/board selection, firmware-reuse-first, breadboard-MVP discipline) | [engineering-team/](engineering-team/) | -| **⚡ Engineering — POWERFUL** | 91 | Agent designer, RAG architect, database designer, CI/CD builder, security auditor, MCP builder, AgentHub, Helm charts, Terraform, self-eval, llm-wiki, tc-tracker, autoresearch-agent, **reliability portfolio** (feature-flags-architect, kubernetes-operator, chaos-engineering, slo-architect), ship-gate, security-guidance PreToolUse hook, **Matt Pocock skills** (write-a-skill, caveman, grill-me, handoff, grill-with-docs), **zero-hallucination-coder** (Discuss→Map→Decompose→Execute→Verify), **agent-harness** (goal→plan→execute→verify→close loops over any domain), **memory-engineering** (price the memory write path, pick which cost to pay, audit FACT/SKILL/LOG density, gate on a forgetting policy), **skillopt-sleep** (nightly gated self-evolution from real Claude Code sessions, vendored from microsoft/SkillOpt), **book-to-skill** (compile a book, docs folder, or spec collection into a knowledge-base skill, then package it as a plugin), **boost-asio-pro** (async C++ networking — version-gated coroutine/callback styles, strand discipline), **human-gate** (batched human review as a structured artifact + a gate that refuses to close on open blockers), **agent-memory** (four-tier L0-L3 memory ladder over Claude Code hooks; promotion earned by recurrence across sessions and days, redacted or contested claims refuse to promote, nothing reaches CLAUDE.md without a human adopt), **skill-doctor** (grade the agent setup from real session history — rubric-judged, deterministically gated, evidence-traced skill edits) | [engineering/](engineering/) | +| **⚡ Engineering — POWERFUL** | 92 | Agent designer, RAG architect, database designer, CI/CD builder, security auditor, MCP builder, AgentHub, Helm charts, Terraform, self-eval, llm-wiki, tc-tracker, autoresearch-agent, **reliability portfolio** (feature-flags-architect, kubernetes-operator, chaos-engineering, slo-architect), ship-gate, security-guidance PreToolUse hook, **Matt Pocock skills** (write-a-skill, caveman, grill-me, handoff, grill-with-docs), **zero-hallucination-coder** (Discuss→Map→Decompose→Execute→Verify), **agent-harness** (goal→plan→execute→verify→close loops over any domain), **memory-engineering** (price the memory write path, pick which cost to pay, audit FACT/SKILL/LOG density, gate on a forgetting policy), **skillopt-sleep** (nightly gated self-evolution from real Claude Code sessions, vendored from microsoft/SkillOpt), **book-to-skill** (compile a book, docs folder, or spec collection into a knowledge-base skill, then package it as a plugin), **boost-asio-pro** (async C++ networking — version-gated coroutine/callback styles, strand discipline), **human-gate** (batched human review as a structured artifact + a gate that refuses to close on open blockers), **agent-memory** (four-tier L0-L3 memory ladder over Claude Code hooks; promotion earned by recurrence across sessions and days, redacted or contested claims refuse to promote, nothing reaches CLAUDE.md without a human adopt), **skill-doctor** (grade the agent setup from real session history — rubric-judged, deterministically gated, evidence-traced skill edits), **deep-learning-book** (companion to the free Goodfellow/Bengio/Courville textbook — 20 chapter files with a 2016→2026 delta layer, reading-path planner, Ch-11 training diagnostic, capacity planner, FLOP/activation-memory calculator) | [engineering/](engineering/) | | **🎯 Product** | 17 | Product manager, agile PO, strategist, UX researcher, UI design, landing pages, SaaS scaffolder, analytics, experiment designer, discovery, roadmap communicator, code-to-prd, apple-hig-expert | [product-team/](product-team/) | | **📣 Marketing** | 49 | 8 pods: Content, SEO + AEO (`aeo` — E-E-A-T audit, citation tracking across 5 LLMs) + local (`local-seo-manager` — GBP/NAP/Map-Pack), CRO, Channels, Growth, Intelligence, Sales + `business-name-fit` (cross-cultural naming) + context foundation + orchestration router | [marketing-skill/](marketing-skill/) | | **🚀 Productivity** | 12 | `capture` (brain-dump-to-action), `email` pair (inbox-setup + inbox-triage), `reflect` (journal), `handoff` (Matt Pocock-inspired), `andreessen` (market-first decision mode), `roast` (5-angle idea panel → GO/RESHAPE/KILL), `fable-goal` (ramble → autonomous /goal prompt), `weekly-review` (GTD loop with refusal gate), `deep-work` (time-blocking + shallow-work budget), `meetings` (cost gate + agenda + action items), `swedish-mentor` (CEFR-leveled Swedish learning paths) | [productivity/](productivity/) | -| **🎨 Marketing (top-level)** | 1 | `landing` — single-file HTML landing-page generator (4 design styles, GSAP patterns, brand palette validator) | [marketing/](marketing/) | +| **🎨 Marketing (top-level)** | 7 | `landing` — single-file HTML landing-page generator (4 design styles, GSAP patterns, brand palette validator); `linkedin` — organic LinkedIn presence with User Agreement §8.2 refusals in code (orchestrator + profile, strategy, content, engagement, analytics) | [marketing/](marketing/) | | **🔬 Research (academic)** | 10 | `research` orchestrator (hybrid router + fallback) + 8 specialists: `pulse`, `litreview`, `grants` (NIH), `dossier`, `patent`, `syllabus`, `notebooklm`, `deep-research` (rigor-first meta-research), `deepread` (evidence-first reading of supplied documents) | [research/](research/) | | **🧪 Research Operations** ✨v2.9.0 | 5 | Enterprise/cross-functional research: orchestrator + `clinical-research` (study design), `research-finance` (R&D program finance), `market-research` (sizing/survey/segmentation), `product-research` (user research) — each with onboarding + customization + opt-in autoresearch bridge | [research-ops/](research-ops/) | | **📋 Project Management** | 9 | Senior PM, scrum master, Jira, Confluence, Atlassian admin, templates + bundled Atlassian Remote MCP | [project-management/](project-management/) | @@ -359,7 +359,7 @@ Yes. Skills work natively with 13 tools: Claude Code, OpenAI Codex, Gemini CLI, No. We follow semantic versioning and maintain backward compatibility within patch releases. Existing script arguments, plugin source paths, and SKILL.md structures are never changed in patch versions. See the [CHANGELOG](CHANGELOG.md) for details on each release. **Are the Python tools dependency-free?** -Yes. All 706 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly. +Yes. All 727 Python tools use the standard library only — zero pip installs required. Every skill's CLI entry point is verified to run with `--help` (most skills ship one script per tool; a few, like the vendored `engineering/skillopt-sleep` engine, ship a multi-module package behind a single `python -m` entry point). A few tools — `engineering/book-to-skill`'s document extractors — can *optionally* use third-party parsers for higher-fidelity output, but every format falls back to a standard-library parser and nothing is installed implicitly. **How do I create my own Claude Code skill?** Each skill is a folder with a `SKILL.md` (frontmatter + instructions), optional `scripts/`, `references/`, and `assets/`. See the [Skills & Agents Factory](https://github.com/alirezarezvani/claude-code-skills-agents-factory) for a step-by-step guide. diff --git a/engineering/deep-learning-book/.claude-plugin/authoring-notes.json b/engineering/deep-learning-book/.claude-plugin/authoring-notes.json new file mode 100644 index 000000000..154fba761 --- /dev/null +++ b/engineering/deep-learning-book/.claude-plugin/authoring-notes.json @@ -0,0 +1,12 @@ +{ + "source": { + "spec": "User request: convert https://www.deeplearningbook.org/ into a shareable skill.", + "build_pattern": "Companion, not compilation. Follows the book-to-skill OUTPUT SHAPE (master SKILL.md under 4k tokens with a chapter index and topic index, chapters/chNN-*.md, glossary.md, patterns.md, cheatsheet.md — validated by book-to-skill's own book_skill_validator.py) while deliberately NOT running its extraction pipeline. Every chapter file is original synthesis written from domain knowledge, linking to the official free chapter; the organizing structure is the book's published table of contents. Adds four stdlib-only tools, four references citing 7-8 sources each with per-claim confidence levels, and three assets.", + "distinct_from": "engineering/book-to-skill (the converter — this is the artifact its rights gate calls for when the source is copyrighted); engineering-team/senior-ml-engineer (production MLOps and serving, not the theory); engineering/llm-cost-optimizer (LLM economics, which the book predates); engineering/memory-engineering and engineering/agent-memory (agent systems, not model training); the teach/learn skills (general study workflows, not one specific text with its dependency graph and expiry dates)." + }, + "attribution": { + "source_work": "Deep Learning, Ian Goodfellow, Yoshua Bengio and Aaron Courville, MIT Press, 2016 — https://www.deeplearningbook.org/. All rights remain with the authors and publisher. This companion is independent and unaffiliated, and is not endorsed by the authors or MIT Press.", + "rights_basis": "No rights basis is claimed or needed, because no protected expression is reproduced. The book is copyrighted and its site states the HTML-only format exists as a deliberate friction against copying under the authors' MIT Press contract; PDF distribution is not permitted. book-to-skill's rights gate would classify it 'local' (none of public-domain / open-license / internal-docs / author-permission applies), and its rights reference lists publishing a compiled skill of a copyrighted book to a public marketplace under 'Do not'. This plugin therefore contains no passages, paragraphs, figures, exercises, or per-paragraph paraphrase — only original synthesis, the factual table of contents, and links to the official free chapters. See skills/deep-learning-book/references/rights_and_use.md.", + "evidence_discipline": "Every claim about what changed since 2016 carries a confidence level (well-established / contested / folklore) and a primary citation, and two commonly repeated claims are marked contested rather than propagated: batch normalization's 'internal covariate shift' mechanism (challenged by Santurkar et al. 2018) and the strong form of the lottery-ticket hypothesis. Two pieces of folklore are named as folklore, including the local-minima fear the book itself corrects." + } +} diff --git a/engineering/deep-learning-book/.claude-plugin/plugin.json b/engineering/deep-learning-book/.claude-plugin/plugin.json new file mode 100644 index 000000000..644a495bf --- /dev/null +++ b/engineering/deep-learning-book/.claude-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "deep-learning-book", + "description": "Study companion and working knowledge base for the Deep Learning textbook by Goodfellow, Bengio & Courville (MIT Press, 2016), free to read at deeplearningbook.org. Twenty chapter files, a glossary, a patterns file and a cheatsheet index the whole book; every chapter carries a 'what changed after 2016' section, and a dedicated delta reference dates the text against 2026 practice with per-claim confidence levels — double descent qualifying the U-curve, AdamW splitting weight decay from L2, transformers displacing Chapter 10's recurrence, diffusion growing out of Chapter 18's score matching, and self-supervised learning vindicating Chapter 15 while replacing its methods. Four stdlib-only tools make the book executable: a prerequisite-closed reading-path planner that refuses goals the 2016 book does not cover, a training diagnostic running Chapter 11's rules in priority order so a NaN is never reported as overfitting, a capacity planner that ranks the regularization ladder and pushes 'shrink the model' last in the overparameterized regime, and a parameter/FLOP/activation-memory calculator that refuses a stack whose shapes do not connect. Deliberately a companion, not a compilation: the book is copyrighted, so nothing here reproduces its text — every chapter file is original synthesis linking to the official free chapter. Use when studying or teaching this book, planning a route through it, or checking whether one of its recommendations is still current.", + "version": "2.12.0", + "author": { + "name": "Alireza Rezvani", + "url": "https://alirezarezvani.com" + }, + "homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/engineering/deep-learning-book", + "repository": "https://github.com/alirezarezvani/claude-skills", + "license": "MIT", + "skills": [ + "./skills/deep-learning-book" + ] +} diff --git a/engineering/deep-learning-book/README.md b/engineering/deep-learning-book/README.md new file mode 100644 index 000000000..66cd565ba --- /dev/null +++ b/engineering/deep-learning-book/README.md @@ -0,0 +1,118 @@ +# deep-learning-book — study companion for *Deep Learning* (Goodfellow, Bengio & Courville) + +A navigable, executable, **date-stamped** companion to the 2016 MIT Press textbook that is free +to read at [deeplearningbook.org](https://www.deeplearningbook.org/). + +Twenty chapter files, a glossary, a patterns file, a cheatsheet, four references and four +deterministic tools — plus the thing a static compilation cannot give you: a **2016 → 2026 delta +layer** that says, per chapter, what still holds, what was superseded, and what is now purely +historical. + +## Why this is a companion and not a compiled skill + +This repository ships [`engineering/book-to-skill`](../book-to-skill/), which compiles a document +on disk into a knowledge-base skill. It was deliberately **not** used here, for three reasons its +own rights gate makes binding: + +1. The book is copyrighted (MIT Press, 2016). Free to read ≠ free to redistribute. +2. deeplearningbook.org states that its HTML-only presentation is a deliberate friction against + copying, required by the authors' contract, and that PDF distribution is not permitted. +3. This repository is public, and `book-to-skill`'s rights gate refuses a `shareable` package + without `public-domain`, `open-license`, `internal-docs` or `author-permission`. None applies. + (Its hard rule 1 also forbids scraping a book from the web, so the pipeline could not have run + against a URL in any case.) + +So this skill contains **no passages, paragraphs, figures, or per-paragraph paraphrase**. Every +chapter file is original synthesis — what the chapter establishes, how to use it, where it has +aged — with a link to the official free chapter. The organizing structure is the book's published +table of contents, which is factual metadata. Full reasoning in +[`references/rights_and_use.md`](skills/deep-learning-book/references/rights_and_use.md). + +**The rule this sets for the repository:** when a user asks to convert a copyrighted work into a +shareable skill, build a companion that indexes and updates the source, not a compilation that +reproduces it. Compile only when the rights gate clears, and keep the output local when it does not. + +## What is in it + +``` +skills/deep-learning-book/ +├── SKILL.md core frameworks + chapter index + topic index (~1.9k tokens, resident) +├── chapters/ch01..ch20 one file per chapter, each with "What changed after 2016" +├── glossary.md every key term → its chapter +├── patterns.md techniques as instruments, with trade-offs +├── cheatsheet.md decision tables: loss choice, fit verdict, optimizer defaults +├── references/ delta layer · prerequisite map · study method · rights +├── scripts/ 4 stdlib tools +└── assets/ layer spec · chapter worksheet · study log +``` + +## The tools + +| Tool | Does | Refuses | +|---|---|---| +| `reading_path_planner.py` | Goal + background + hours → prerequisite-closed, ordered path with an hour budget | A goal outside the book (exit 3, names what covers it); an unroutable goal (exit 4, prints the questions) | +| `training_diagnostics.py` | Measurements → ranked cause + next action + chapter, rules in priority order | Diagnosing with no instruments (exit 4). Never reports a NaN as overfitting | +| `capacity_planner.py` | Gap + params-per-example → ordered regularization ladder | A validation error materially below training error (exit 4 — leaky split) | +| `model_arithmetic.py` | Layer stack → parameters, FLOPs, activation memory per example | A stack whose shapes do not connect (exit 5); an unknown layer type (exit 4) | + +All four are standard-library only, support `--help` / `--sample` / `--output json`, make no +network calls, and load no frameworks. + +## The delta layer + +The book was published in 2016; *Attention Is All You Need* appeared in 2017. Five corrections +carry most of the weight, each cited and confidence-rated in +[`references/book_to_2026_delta.md`](skills/deep-learning-book/references/book_to_2026_delta.md): + +1. **Double descent** qualifies Chapter 5's U-shaped capacity curve — "shrink the model when it + overfits" is no longer the only correct move, and `capacity_planner.py` encodes the correction. +2. **AdamW**: weight decay and L2 are not equivalent under an adaptive optimizer; Chapter 7 treats + them as interchangeable. +3. **Transformers** displaced Chapter 10's recurrence — keep its gradient-flow analysis, drop its + architecture recommendation. (State-space models made that analysis live again.) +4. **Diffusion** grew directly out of Chapter 14's denoising autoencoders and Chapter 18's score + matching, and displaced Chapter 20's model list. +5. **Self-supervised learning** vindicated Chapter 15's bet while replacing every method it names; + unsupervised disentanglement was proven impossible without inductive bias. + +The general rule the file states: the conflict is almost always in the **recommendation**, not the +**analysis**. Keep the diagnosis, replace the prescription. + +## Use it + +```bash +S=engineering/deep-learning-book/skills/deep-learning-book/scripts + +python3 $S/reading_path_planner.py --goal "train and debug convnets" --background applied --hours-per-week 5 +python3 $S/training_diagnostics.py --train-loss 0.02 --val-loss 1.9 --tiny-subset-fits yes +python3 $S/capacity_planner.py --params 12000000 --train-examples 50000 --train-error 0.01 --val-error 0.22 +python3 $S/model_arithmetic.py --spec skills/deep-learning-book/assets/example_layer_spec.json +``` + +Slash commands: `/cs:deep-learning` (navigate and date the answer), `/cs:dl-reading-path`, +`/cs:dl-diagnose`. Agent: `cs-deep-learning-tutor`. + +## Distinct from + +- **`engineering/book-to-skill`** — the converter. This is what you build when its rights gate + says no. +- **`engineering-team/senior-ml-engineer`** — production MLOps, deployment, serving. This is the + theory underneath. +- **`engineering/llm-cost-optimizer`** — LLM economics, which the book predates entirely. +- **`teach` / `learn` skills** — general study workflows. This is one specific text, with its + dependency graph and its expiry dates. + +## Scope + +The twenty chapters and the delta between them and 2026 practice. **Not covered**, because the +book does not cover them: reinforcement learning beyond passing mention, LLM training +infrastructure, RLHF/DPO, agentic systems, MLOps tooling, fairness and safety evaluation. The +skill is built to say so rather than improvise. + +## Attribution + +*Deep Learning*, Ian Goodfellow, Yoshua Bengio and Aaron Courville, MIT Press, 2016 — +https://www.deeplearningbook.org/. All rights in the book remain with its authors and publisher. +This companion is an independent work: no text, figures, or exercises from the book are +reproduced here, and it is not endorsed by or affiliated with the authors or MIT Press. The +companion's own content is MIT-licensed as part of this repository. diff --git a/engineering/deep-learning-book/agents/cs-deep-learning-tutor.md b/engineering/deep-learning-book/agents/cs-deep-learning-tutor.md new file mode 100644 index 000000000..81f408107 --- /dev/null +++ b/engineering/deep-learning-book/agents/cs-deep-learning-tutor.md @@ -0,0 +1,61 @@ +--- +name: cs-deep-learning-tutor +description: "Study companion for the Deep Learning textbook (Goodfellow, Bengio & Courville, 2016). Plans a prerequisite-closed reading path, answers chapter questions from the compiled knowledge base, diagnoses training runs against Chapter 11's decision tree, and flags every place the 2016 text has been superseded. Use for studying the book, teaching from it, or checking whether one of its recommendations is still current." +--- + +# Deep Learning Tutor + +You are a study companion for *Deep Learning* by Ian Goodfellow, Yoshua Bengio and Aaron +Courville (MIT Press, 2016), which is free to read at deeplearningbook.org. + +## What you are working from + +`engineering/deep-learning-book/skills/deep-learning-book/` — a master SKILL.md with core +frameworks and two indexes, 20 chapter files, a glossary, a patterns file, a cheatsheet, four +references and four tools. Read the SKILL.md first, resolve the question through the Topic +Index, then read that chapter file before answering. + +## Hard rules + +1. **Never reproduce the book's text.** Not a paragraph, not a figure, not a + sentence-by-sentence paraphrase. Point the reader at the official chapter URL and explain in + your own words. This is the constraint the whole skill is built around — see + `skills/deep-learning-book/references/rights_and_use.md`. +2. **Date every recommendation.** The book is from 2016 and *Attention Is All You Need* is from + 2017. When a chapter's advice has been superseded, say so and cite + `skills/deep-learning-book/references/book_to_2026_delta.md`. Never present a 2016 recommendation as current practice + without that check. +3. **Separate the analysis from the prescription.** The book's diagnoses (why gradients vanish, + why the partition function is hard, why depth helps) almost all still hold. Its + prescriptions (use an LSTM, use Adam with L2, shrink the model when it overfits) frequently + do not. Keep the diagnosis, replace the prescription. +4. **Say when the book does not cover something.** RLHF, LLM infrastructure, agents, MLOps, + fairness — name the gap and route elsewhere rather than improvising the book's position. +5. **Read the chapter file before answering from it.** The indexes are for navigation, not for + answering. +6. **Run the tool rather than estimating.** Reading paths, training diagnoses, capacity plans + and parameter counts all have deterministic tools. Use them, then interpret the output. + +## How you work + +**When asked where to start** — run `reading_path_planner.py` with the stated goal, background +and weekly hours. If it exits 3 or 4, relay its questions rather than guessing a path. + +**When asked about a topic** — resolve through the Topic Index, read the chapter file, answer, +and always surface the "What changed after 2016" section if one applies. + +**When asked to diagnose a training run** — ask for the measurements the tool needs +(train loss, val loss, target loss, gradient norm, whether it can overfit a tiny subset), run +`training_diagnostics.py`, and act on finding [1] before anything below it. Do not skip to the +interesting hypothesis; the rule order exists because a NaN is not an overfitting problem. + +**When teaching** — use the retrieval-practice cadence in `skills/deep-learning-book/references/study_method_canon.md`: +ask the reader to state the core idea from memory first, then correct. Do not lecture the +chapter at someone who has just read it. + +## Voice + +Direct and specific. Name the chapter for every claim. When the reader's plan is wrong — front +to back through Part I, or a Part III chapter without its prerequisites — say so once, give the +alternative, and let them decide. When something in the book is simply out of date, say that +plainly rather than defending it; a companion that will not date its source is worthless. diff --git a/engineering/deep-learning-book/commands/cs-deep-learning.md b/engineering/deep-learning-book/commands/cs-deep-learning.md new file mode 100644 index 000000000..b9e115f63 --- /dev/null +++ b/engineering/deep-learning-book/commands/cs-deep-learning.md @@ -0,0 +1,55 @@ +--- +name: "cs-deep-learning" +description: "/cs:deep-learning — Study companion for the Deep Learning textbook (Goodfellow, Bengio & Courville, 2016). Answers chapter questions from a compiled knowledge base, always dating the answer against 2026 practice, and routes to the reading-path planner or the training diagnostic. Points at the free official chapters; never reproduces them." +argument-hint: "[a topic, a chapter number, a question about the book, or 'where do I start']" +--- + +# /cs:deep-learning — Navigate the book, and date its advice + +**Command:** `/cs:deep-learning [topic | chNN | question]` + +The book is free to read at [deeplearningbook.org](https://www.deeplearningbook.org/). This +command navigates it and keeps it current; it does not replace it. + +## When to run + +- "What does the book say about regularization / saddle points / the partition function?" +- "Is Chapter 10's advice on LSTMs still right?" +- "Explain the ELBO the way Chapter 19 sets it up" +- "Where should I start?" (routes to `/cs:dl-reading-path`) +- "Why is my training run doing this?" (routes to `/cs:dl-diagnose`) + +## When NOT to run + +- Production ML engineering → `engineering-team/senior-ml-engineer` +- LLM cost and serving → `engineering/llm-cost-optimizer` +- RLHF, agents, prompting, MLOps → outside the book entirely; this command will say so + +## Procedure + +1. **Load** `engineering/deep-learning-book/skills/deep-learning-book/SKILL.md`. +2. **Resolve** the request through the Topic Index to one or more chapters. +3. **Read** those chapter files before answering. The index is navigation, not content. +4. **Answer** in your own words, naming the chapter, and link the official chapter URL. +5. **Date it.** If the chapter file has a "What changed after 2016" section relevant to the + answer, surface it — with the confidence level from + `skills/deep-learning-book/references/book_to_2026_delta.md`. Separate the book's *analysis* (usually still true) from + its *prescription* (frequently superseded). +6. **Name the boundary.** If the question is outside the twenty chapters, say so and route. + +## The four things this command will not do + +- Reproduce the book's text, figures, or a paragraph-by-paragraph paraphrase. +- Present a 2016 recommendation as current practice without checking the delta layer. +- Answer from the index without reading the chapter file. +- Improvise the book's position on material published after it. + +## Output shape + +``` +Chapter(s): ch07 (Regularization), ch05 (capacity) +Answer : +Still true: + why +Read it : https://www.deeplearningbook.org/contents/regularization.html +Next : +``` diff --git a/engineering/deep-learning-book/commands/cs-dl-diagnose.md b/engineering/deep-learning-book/commands/cs-dl-diagnose.md new file mode 100644 index 000000000..9c47240e8 --- /dev/null +++ b/engineering/deep-learning-book/commands/cs-dl-diagnose.md @@ -0,0 +1,52 @@ +--- +name: "cs-dl-diagnose" +description: "/cs:dl-diagnose — Diagnose a training run against Chapter 11's decision tree: read training error before deciding anything, and never diagnose a NaN as overfitting. Returns a ranked cause, the specific next action, and the chapter that justifies it." +argument-hint: "[what the run is doing — losses, gradient norm, or a description]" +--- + +# /cs:dl-diagnose — Measurement first, hypothesis second + +**Command:** `/cs:dl-diagnose [symptoms]` + +Chapter 11's rule, which most teams have backwards: **read training error first.** High +training error means the model or the optimizer is the bottleneck, and more data cannot help. + +## Procedure + +1. **Collect the instruments.** Ask for what is missing, in this order: + - training loss and validation loss (same units, same epoch) + - the target loss — a human baseline, a published number, or an irreducible-error estimate + (without it, underfitting cannot be distinguished from convergence) + - global gradient norm, if available + - has the loss ever gone NaN or inf? + - can the model drive training loss to ~0 on 10–50 examples? (the smoke test that separates + a bug from a hard problem) +2. **Run the tool:** + ```bash + python3 engineering/deep-learning-book/skills/deep-learning-book/scripts/training_diagnostics.py \ + --train-loss --val-loss --target-loss --grad-norm \ + --tiny-subset-fits yes|no|unknown + ``` + Exit 4 means not enough instruments — ask for one of the named measurements rather than + guessing. +3. **Act on finding [1] first.** Rules fire in priority order for a reason: a non-finite loss is + a numerics failure, not a modelling one, and a model that cannot overfit 20 examples has a + bug that no hyperparameter will fix. +4. **On an OVERFIT verdict**, follow up with the capacity planner, which ranks the + regularization ladder and applies the double-descent correction: + ```bash + python3 .../capacity_planner.py --params --train-examples \ + --train-error --val-error --applied early-stopping + ``` +5. **On a memory or throughput question**, run `model_arithmetic.py --spec ` — it reports + parameters, FLOPs and activation memory per example, and refuses a stack whose shapes do not + connect. +6. **Close with the discipline, not just the fix:** change one thing per experiment, log it, + re-measure the gap. + +## Do not + +- Skip to the interesting hypothesis before the rules have been read in order. +- Recommend collecting data while training error is high. +- Recommend shrinking an overparameterized model first — see the double-descent caveat in + `skills/deep-learning-book/references/book_to_2026_delta.md`. diff --git a/engineering/deep-learning-book/commands/cs-dl-reading-path.md b/engineering/deep-learning-book/commands/cs-dl-reading-path.md new file mode 100644 index 000000000..8eefddc4a --- /dev/null +++ b/engineering/deep-learning-book/commands/cs-dl-reading-path.md @@ -0,0 +1,39 @@ +--- +name: "cs-dl-reading-path" +description: "/cs:dl-reading-path — Build a prerequisite-closed reading path through the Deep Learning book from a goal, a background and the hours you actually have. Refuses to route a goal the 2016 book does not cover, and names what covers it instead." +argument-hint: "[your goal — what you want to be able to do after reading]" +--- + +# /cs:dl-reading-path — A route, not a page count + +**Command:** `/cs:dl-reading-path [goal]` + +The book's part order is not its dependency order. Front-to-back means weeks in Chapters 2–4 +before touching a network, which is where most readers stop. + +## Procedure + +1. **Ask three things** if the user has not said them, one at a time: + - What do you want to be able to *do* afterwards? + - Background: `none` / `math` / `applied` / `research`? + - Realistic study hours per week? +2. **Run the planner:** + ```bash + python3 engineering/deep-learning-book/skills/deep-learning-book/scripts/reading_path_planner.py \ + --goal "" --background --hours-per-week + ``` +3. **Handle the refusals rather than working around them.** + - Exit 3 — the goal is outside the book. Relay what covers it; do not invent a path. + - Exit 4 — the goal is unroutable. Ask the printed questions, one per turn. +4. **Relay the path** with the hour budget, and flag that the hours are a planning heuristic to + recalibrate after chapter one. +5. **Offer the study scaffolding**: `skills/deep-learning-book/assets/study_log_template.md` and + `skills/deep-learning-book/assets/chapter_worksheet.md`, plus the retrieval-practice cadence from + `skills/deep-learning-book/references/study_method_canon.md`. + +## What to say about Part I + +Most applied readers should skim Chapters 2–4 once for vocabulary, start at Chapter 5, and +return to a specific section when Chapter 8 needs it. Readers heading for Part III should work +Part I properly — those chapters compound, and so do the gaps. +`skills/deep-learning-book/references/prerequisite_map.md` has the dependency graph and the three strategies. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/SKILL.md b/engineering/deep-learning-book/skills/deep-learning-book/SKILL.md new file mode 100644 index 000000000..b39aba5d1 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/SKILL.md @@ -0,0 +1,194 @@ +--- +name: deep-learning-book +description: "Study companion and working knowledge base for the Deep Learning textbook by Goodfellow, Bengio & Courville (MIT Press, 2016), read free at deeplearningbook.org. Indexes all 20 chapters, carries a 2016-to-2026 delta layer naming what the book got right, what was superseded (transformers, AdamW, diffusion, double descent) and what still holds, and ships four deterministic tools: a prerequisite-aware reading-path planner, a training-failure diagnostic, a capacity-and-regularization planner, and a parameter/FLOP/activation-memory calculator. Use when studying or teaching this book, planning a route through it, deciding whether a chapter's advice is still current, or translating its math into a training decision. It points at the official chapters — it never reproduces them." +license: MIT +metadata: + version: 1.0.0 + author: Alireza Rezvani + category: engineering + updated: 2026-08-25 +--- + +# Deep Learning — Study Companion + +**Source book**: *Deep Learning*, Ian Goodfellow, Yoshua Bengio & Aaron Courville +(MIT Press, 2016) · 20 chapters, 3 parts · read free at +[deeplearningbook.org](https://www.deeplearningbook.org/) · companion compiled 2026-08-25. + +**This is a companion, not a copy.** The book is copyrighted, and its site states that the +HTML-only format exists to discourage copying under the authors' MIT Press contract. Nothing +here reproduces its text. Every chapter file is original synthesis — what the chapter +establishes, how to use it, where it has aged — plus a link to the official chapter. Read the +book at the link; use this to navigate it, keep it current, and turn it into decisions. +See [references/rights_and_use.md](references/rights_and_use.md). + +## How to Use This Skill + +- **No argument** — load the core frameworks below. +- **A topic** — ask about `regularization`, `saddle points`, `partition function`; resolved + through the Topic Index, then that chapter file is read before answering. +- **`chNN`** — load that chapter's file. +- **"is this still true?"** — the 2016→2026 delta layer, in every chapter file and in + [references/book_to_2026_delta.md](references/book_to_2026_delta.md). +- **"where do I start?"** — run `scripts/reading_path_planner.py`. + +When asked about something outside these 20 chapters, say so and route to the delta reference +rather than improvising the book's position on material published after it. + +--- + +## Core Frameworks & Mental Models + +### The (T, P, E) frame — ch05 + +Name the **task**, the **performance measure**, and the **experience** in one sentence before any +model code. Most failed projects failed at P: an unstated metric, or a proxy whose relationship +to the real objective was never checked. + +### Every loss is a negative log-likelihood — ch03, ch06 + +Choose the output distribution, then take its negative log. Gaussian → MSE, Bernoulli → binary +cross-entropy, categorical → cross-entropy, Laplace → MAE. "Which loss?" is always the question +"which distribution?" in disguise. Modern contrastive and preference objectives sit outside this +frame — a real limit of the book, not a gap in your understanding. + +### KL asymmetry decides your failure mode — ch03, ch19, ch20 + +D(p‖q) ≠ D(q‖p). Forward KL is mode-covering (blurry averages); reverse KL is mode-seeking +(sharp but partial). This single fact predicts VAE blur, GAN mode collapse, and the +characteristic over-confidence of mean-field variational posteriors. + +### Train-error-first triage — ch11, ch05 + +High training error → capacity or optimization is the bottleneck; **more data will not help**. +Low training error with a large validation gap → data or regularization. This is the highest-value +heuristic in the book. `scripts/training_diagnostics.py` runs it. + +### Capacity, the gap, and the U-curve's caveat — ch05, ch07 + +Regularization trades variance for bias. But the classical U-shaped capacity curve is incomplete: +past the interpolation threshold, test error can fall again (double descent, 2019–2020, +post-dating the book). Practical consequence: when a large model overfits, try more data, more +regularization or longer training **before** shrinking it. + +### Architecture is a prior, not a trick — ch09, ch10, ch15 + +Convolution asserts translation equivariance and locality. Recurrence asserts that the past +compresses into a state. A distributed representation asserts that factors combine +combinatorially. When the assertion is false, the architecture cannot be rescued by tuning — and +when it is true, it beats capacity. This is also why Vision Transformers need more data than +ConvNets: they discard the prior and buy it back with examples. + +### Depth's real cost is gradient flow and activation memory — ch06, ch08, ch10 + +Backprop is the chain rule scheduled well: one forward-pass-equivalent of compute, and memory +proportional to stored activations. Depth fails through vanishing/exploding gradients and +ill-conditioning, which is why residual connections, normalization and clipping exist. + +### The partition function organizes Part III — ch16, ch17, ch18, ch19 + +For undirected models, the likelihood gradient needs samples from the model itself. Four escape +routes: sample it (CD/PCD), sidestep it algebraically (pseudolikelihood, **score matching**), +learn around it (NCE), or estimate it for evaluation (AIS). Score matching's descendants are +today's diffusion models — which is why Part III repays reading even though its models did not +survive. + +### Diagnose before you redesign — ch04, ch08, ch11 + +Gradient norm exploding → clip. Norm large but loss flat → ill-conditioning. Norm near zero with +high loss → saturation or dead units. NaN → numerics first. Change one thing per experiment. + +--- + +## Chapter Index + +| # | Title | Key content | +|---|-------|-------------| +| [ch01](chapters/ch01-introduction.md) | Introduction | representation learning, depth as composition, curse of dimensionality | +| [ch02](chapters/ch02-linear-algebra.md) | Linear Algebra | norms, SVD, eigendecomposition, conditioning, PCA | +| [ch03](chapters/ch03-probability-information-theory.md) | Probability & Information Theory | distributions, entropy, KL, cross-entropy | +| [ch04](chapters/ch04-numerical-computation.md) | Numerical Computation | under/overflow, conditioning, gradient descent, KKT | +| [ch05](chapters/ch05-machine-learning-basics.md) | Machine Learning Basics | capacity, bias–variance, No Free Lunch, MLE, manifolds | +| [ch06](chapters/ch06-deep-feedforward-networks.md) | Deep Feedforward Networks | output/hidden units, universal approximation, backprop | +| [ch07](chapters/ch07-regularization.md) | Regularization | norm penalties, augmentation, early stopping, dropout | +| [ch08](chapters/ch08-optimization.md) | Optimization | SGD, momentum, init, Adam, batch norm, saddles | +| [ch09](chapters/ch09-convolutional-networks.md) | Convolutional Networks | sparse interactions, sharing, equivariance, pooling | +| [ch10](chapters/ch10-sequence-modeling.md) | Sequence Modeling | BPTT, vanishing gradients, LSTM/GRU, attention | +| [ch11](chapters/ch11-practical-methodology.md) | Practical Methodology | metrics, baselines, the data-vs-capacity rule, debugging | +| [ch12](chapters/ch12-applications.md) | Applications | scaling, compression, vision, speech, NLP (dated) | +| [ch13](chapters/ch13-linear-factor-models.md) | Linear Factor Models | PPCA, factor analysis, ICA, sparse coding | +| [ch14](chapters/ch14-autoencoders.md) | Autoencoders | undercomplete, sparse, denoising, contractive | +| [ch15](chapters/ch15-representation-learning.md) | Representation Learning | transfer, distributed codes, disentanglement | +| [ch16](chapters/ch16-structured-probabilistic-models.md) | Structured Probabilistic Models | directed/undirected, energy-based, d-separation | +| [ch17](chapters/ch17-monte-carlo-methods.md) | Monte Carlo Methods | importance sampling, MCMC, Gibbs, mixing | +| [ch18](chapters/ch18-partition-function.md) | Confronting the Partition Function | CD/PCD, pseudolikelihood, score matching, NCE, AIS | +| [ch19](chapters/ch19-approximate-inference.md) | Approximate Inference | ELBO, EM, mean field, amortization | +| [ch20](chapters/ch20-deep-generative-models.md) | Deep Generative Models | Boltzmann machines, VAE, GAN, autoregressive | + +## Topic Index + +- **Activation functions, ReLU, GELU** → ch06 +- **Adam, AdamW, adaptive optimizers** → ch08, ch07 +- **Attention, transformers** → ch10, ch12 +- **Autoencoders, denoising, sparse** → ch14, ch13 +- **Backpropagation, autodiff** → ch06 +- **Batch / layer normalization** → ch08 +- **Bias–variance, double descent** → ch05 +- **Convolution, pooling, receptive field** → ch09 +- **Cross-entropy, KL divergence, entropy** → ch03 +- **Diffusion, score matching** → ch18, ch14, ch20 +- **Dropout, weight decay, early stopping** → ch07 +- **ELBO, variational inference, EM** → ch19 +- **Energy-based models, graphical models** → ch16 +- **GANs, VAEs, generative taxonomy** → ch20 +- **Gradient clipping, exploding/vanishing** → ch10, ch08 +- **Hyperparameter search** → ch11 +- **Initialization** → ch08 +- **LSTM, GRU, BPTT, teacher forcing** → ch10 +- **Maximum likelihood, MAP** → ch05, ch03 +- **MCMC, Gibbs, importance sampling** → ch17 +- **Numerical stability, softmax, log-space** → ch04 +- **Partition function, CD, PCD, NCE** → ch18, ch16 +- **PCA, ICA, factor analysis** → ch13, ch02 +- **Representation learning, transfer, probes** → ch15, ch01 +- **Saddle points, ill-conditioning** → ch08, ch04 +- **SVD, eigendecomposition, condition number** → ch02 +- **Training diagnostics, metric choice** → ch11 +- **Universal approximation** → ch06 + +## Supporting Files + +- [glossary.md](glossary.md) — every key term with its chapter +- [patterns.md](patterns.md) — techniques as instruments, with trade-offs +- [cheatsheet.md](cheatsheet.md) — decision tables and defaults +- [references/book_to_2026_delta.md](references/book_to_2026_delta.md) — what changed, per chapter +- [references/prerequisite_map.md](references/prerequisite_map.md) — the real dependency graph +- [references/study_method_canon.md](references/study_method_canon.md) — how to study a hard text +- [references/rights_and_use.md](references/rights_and_use.md) — why this is a companion + +## Tools + +```bash +S=engineering/deep-learning-book/skills/deep-learning-book/scripts +python3 $S/reading_path_planner.py --goal "train a transformer" --background applied --hours-per-week 5 +python3 $S/training_diagnostics.py --train-loss 0.02 --val-loss 1.9 --grad-norm 0.4 --epochs 30 +python3 $S/capacity_planner.py --params 12000000 --train-examples 50000 --train-error 0.01 --val-error 0.22 +python3 $S/model_arithmetic.py --spec-sample +``` + +Every tool supports `--help`, `--sample` and `--output json`, uses the standard library only, and +returns typed exit codes. + +--- + +## Scope & Limits + +This companion covers the 2016 edition's 20 chapters and the delta between them and 2026 +practice. It does **not** cover: reinforcement learning beyond passing mention, LLM training +infrastructure, RLHF/DPO alignment, agentic systems, MLOps tooling, or fairness and safety +evaluation — none of which the book treats. For production ML engineering use +`engineering-team/senior-ml-engineer`; for LLM cost work use `engineering/llm-cost-optimizer`. + +When a question lands outside the book, say the book does not cover it and cite the delta +reference for what replaced its position. A companion that quietly extrapolates is worse than one +that names its boundary. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/assets/chapter_worksheet.md b/engineering/deep-learning-book/skills/deep-learning-book/assets/chapter_worksheet.md new file mode 100644 index 000000000..4790653c7 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/assets/chapter_worksheet.md @@ -0,0 +1,62 @@ +# Chapter Worksheet + +One page per chapter. Fill it from memory **with the book closed**, then check against the +skill's chapter file. The gap between what you wrote and what is there is the actual learning +signal — retrieval practice beats re-reading (see `references/study_method_canon.md`). + +--- + +**Chapter**: ___ **Title**: ______________________ **Date**: __________ +**Time spent**: ______ **Read as**: ☐ first pass ☐ working the derivations ☐ revisit + +## 1. Core idea, in one sentence + +> If this takes more than two sentences, you have summarized rather than extracted. + +_______________________________________________________________________________ + +## 2. Three concepts I could define to someone else + +1. _____________________________________________________________________________ +2. _____________________________________________________________________________ +3. _____________________________________________________________________________ + +## 3. One thing I could not follow + +> Name it precisely — "the derivation on the negative phase" beats "the math." + +_______________________________________________________________________________ + +**Is it blocking?** ☐ yes, a later chapter needs it (which: ____) ☐ no, note and move on + +## 4. Self-explanation prompt + +Pick one equation. **What breaks if this term is removed?** + +_______________________________________________________________________________ + +## 5. When would I actually use this? + +> A note that answers this gets read again. A note that restates the text does not. + +_______________________________________________________________________________ + +## 6. Still true in 2026? + +Check the chapter file's "What changed after 2016" section and this skill's +`references/book_to_2026_delta.md`. + +☐ holds as written ☐ analysis holds, recommendation superseded ☐ historical only + +What replaced it: ___________________________________________________________ + +## 7. The small thing I applied before moving on + +> Five lines of code, one diagnosis of a real run, one re-derivation. This step is the +> one most readers skip and the one that decides whether any of it survives. + +_______________________________________________________________________________ + +## 8. Revisit date + +☐ +1 week: ________ ☐ +1 month: ________ (ch05, ch08, ch11 and ch18 are worth both) diff --git a/engineering/deep-learning-book/skills/deep-learning-book/assets/example_layer_spec.json b/engineering/deep-learning-book/skills/deep-learning-book/assets/example_layer_spec.json new file mode 100644 index 000000000..ea91f7aba --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/assets/example_layer_spec.json @@ -0,0 +1,41 @@ +{ + "name": "transformer encoder block, 768-dim, 512 tokens", + "_comment": "Feed to model_arithmetic.py --spec. Shapes are per example, no batch dim. The two feedforward linears run position-wise over the (512, 768) sequence: one weight matrix shared across all 512 tokens, which is what a transformer FFN actually is. Do NOT insert a flatten before them \u2014 that models a dense layer over the whole flattened sequence and inflates the parameter count by 512x. Note how the mha row's cost grows quadratically with sequence length: double 512 to 1024 and the attention term quadruples while the projections only double.", + "layers": [ + { + "type": "input", + "shape": [ + 512, + 768 + ], + "name": "token embeddings in" + }, + { + "type": "layernorm", + "name": "pre-norm 1" + }, + { + "type": "mha", + "heads": 12, + "name": "self-attention" + }, + { + "type": "layernorm", + "name": "pre-norm 2" + }, + { + "type": "linear", + "units": 3072, + "name": "ffn up (position-wise)" + }, + { + "type": "activation", + "name": "gelu" + }, + { + "type": "linear", + "units": 768, + "name": "ffn down (position-wise)" + } + ] +} diff --git a/engineering/deep-learning-book/skills/deep-learning-book/assets/study_log_template.md b/engineering/deep-learning-book/skills/deep-learning-book/assets/study_log_template.md new file mode 100644 index 000000000..4dc5291af --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/assets/study_log_template.md @@ -0,0 +1,51 @@ +# Study Log — Deep Learning (Goodfellow, Bengio & Courville) + +Started: __________ · Goal: ______________________________________________ +Path from `reading_path_planner.py`: _______________________________________ +Hours available per week: ______ · Estimated weeks: ______ + +## Progress + +| Date | Ch | Hours | Status | Confidence (1-5) | Applied it to | +|------|----|-------|--------|------------------|---------------| +| | | | ☐ read ☐ worked ☐ applied | | | +| | | | | | | +| | | | | | | + +**Confidence** means "could I explain this to a colleague without notes?" — not "did I finish +the pages." A 2 is useful information; record it honestly and put the chapter on the revisit +list. + +## Revisit queue (spaced repetition) + +| Chapter | +1 week | +1 month | Notes | +|---------|---------|----------|-------| +| ch05 | | | vocabulary every later chapter reuses | +| ch08 | | | the optimization diagnoses | +| ch11 | | | the workflow — apply it, do not just read it | +| ch18 | | | score matching → diffusion | + +## Deferred with intent + +Things skipped on purpose, and what would bring them back. + +| Chapter or section | Why deferred | Bring it back when | +|--------------------|--------------|--------------------| +| | | | + +## Open confusions + +Carry these forward — a confusion that survives three chapters is a prerequisite gap, and +`references/prerequisite_map.md` will name the chapter that fills it. + +| # | Confusion | First hit (ch) | Resolved? | +|---|-----------|----------------|-----------| +| 1 | | | | + +## Applied log + +The point of the book. One line per real use. + +| Date | What I was doing | Which chapter helped | Outcome | +|------|------------------|----------------------|---------| +| | | | | diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch01-introduction.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch01-introduction.md new file mode 100644 index 000000000..011c94893 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch01-introduction.md @@ -0,0 +1,59 @@ +# Chapter 1: Introduction + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/intro.html + +## Core Idea + +Deep learning is representation learning stacked: instead of hand-designing the features a +model consumes, you let the model learn a hierarchy of representations, each layer expressed +in terms of the one below it. Depth is the mechanism that makes composition cheap. + +## Key Concepts + +- **Representation learning**: learning the features, not just the mapping from features to output. +- **Depth as composition**: a deep model reuses sub-computations, so a function needing + exponentially many units at depth 1 may need polynomially many at depth k. +- **The curse of dimensionality**: as input dimension grows, the number of distinguishable + configurations grows exponentially, so local-interpolation methods run out of neighbours. +- **Prior of compositionality**: deep learning bets that the data was generated by a composition + of simple factors. This is a prior, not a theorem — where the bet is wrong, depth does not help. +- **AI winters and rebrands**: cybernetics → connectionism → deep learning; the ideas outlived + three funding cycles because the constraint was data and compute, not concept. + +## Mental Models + +- Think of each layer as **re-coordinatizing** the input so the next layer's job gets easier — + not as "extracting meaning." +- Prefer depth over width when the target function plausibly **factors**; prefer width when it + does not. Depth is a structural prior, and priors have to be earned. +- Treat "the model learns features automatically" as **shifting where the human judgment goes** + (architecture, objective, data curation), not eliminating it. + +## Anti-patterns + +- **Reading this chapter as history and skipping it**: the three growth curves it draws + (dataset size, model size, benchmark accuracy) are the argument for why anything else works. +- **Assuming depth is always better**: without the compositional structure, extra depth buys + optimization difficulty and nothing else. + +## What changed after 2016 + +The chapter's central claim — capability tracks dataset and model scale — held, and was made +quantitative by neural scaling laws (Hestness et al. 2017; Kaplan et al. 2020; Hoffmann et al. +2022 "Chinchilla"), which the 2016 text predates. Its examples of "what deep learning can do" +are now badly dated; its explanation of *why* depth helps is not. **Confidence: high** — the +scaling-law literature is public and replicated. + +## Key Takeaways + +1. Before adding depth, state the compositional structure you believe the data has. +2. Use the curse of dimensionality as the test for whether a nearest-neighbour-style baseline + is even viable on your input. +3. Read the growth curves as a resource argument: most 2016-era "deep learning doesn't work + here" results were data or compute verdicts, not architecture verdicts. + +## Connects To + +- **Ch 5**: the formal learning-theory machinery behind the claims made informally here. +- **Ch 15**: representation learning, promised here and delivered there. +- **references/book_to_2026_delta.md**: the scaling-law layer this chapter now needs. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch02-linear-algebra.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch02-linear-algebra.md new file mode 100644 index 000000000..8677bf6c9 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch02-linear-algebra.md @@ -0,0 +1,61 @@ +# Chapter 2: Linear Algebra + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/linear_algebra.html + +## Core Idea + +The minimum linear algebra a deep learning practitioner needs, chosen for what appears later: +tensors and broadcasting, norms, eigendecomposition, SVD, the pseudoinverse, and PCA derived +from scratch. This is a filter, not a course — it teaches what Chapters 4, 5, 8 and 13 will use. + +## Key Concepts + +- **Tensor / broadcasting**: an n-dimensional array plus the rules for combining shapes. Most + real "model bugs" are shape bugs. +- **Norms**: L2 (Euclidean), L1 (sparsity-friendly, non-differentiable at 0), max-norm, + Frobenius. The choice of norm *is* the choice of what "small" means in a regularizer. +- **Eigendecomposition**: A = V diag(λ) V⁻¹ for square matrices; the eigenvalues tell you the + local curvature story later in Ch 4 and 8. +- **Singular value decomposition (SVD)**: A = U D Vᵀ, defined for *any* matrix. The general tool + where eigendecomposition needs square and diagonalizable. +- **Moore–Penrose pseudoinverse**: the least-squares / minimum-norm solution when a system is + over- or under-determined. +- **Condition number**: ratio of largest to smallest singular value; large means small input + perturbations produce large output changes — the numerical fragility Ch 4 confronts. +- **PCA**: derived here as the linear encoder/decoder minimizing L2 reconstruction error, which + is exactly the framing Ch 13–14 generalize. + +## Mental Models + +- Read a matrix as a **function on space**, and its singular values as how much it stretches + each orthogonal direction. Condition number = worst stretch / least stretch. +- Use SVD as the **default** decomposition and reach for eigendecomposition only when symmetry + buys you something (it does for Hessians). +- Treat a regularizer's norm as a **prior over parameter space**: L2 says "small and spread", + L1 says "mostly zero." + +## Anti-patterns + +- **Skipping to Chapter 6.** Ch 4 and 8's discussion of ill-conditioning is unreadable without + eigenvalues and condition number, and readers who skip typically bounce off Ch 8. +- **Memorizing decompositions as identities** instead of as geometry — the geometry is what + transfers to optimization. + +## What changed after 2016 + +Nothing in the mathematics. What changed is the practice: in half-precision training, condition +number stopped being a theoretical concern and became an operational one (loss scaling, bf16 over +fp16 precisely because of dynamic range). **Confidence: high.** + +## Key Takeaways + +1. Track shapes explicitly; treat a shape mismatch as a modelling error, not a typing error. +2. Pick your norm deliberately when you regularize — you are choosing the shape of the prior. +3. Learn PCA in this chapter's form (encoder/decoder minimizing reconstruction) so Ch 13–14 read + as generalizations rather than new material. + +## Connects To + +- **Ch 4**: conditioning and numerical stability use exactly these quantities. +- **Ch 13**: PCA reappears as a linear factor model with an explicit probabilistic story. +- **Ch 8**: Hessian eigenvalues explain why gradient descent stalls. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch03-probability-information-theory.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch03-probability-information-theory.md new file mode 100644 index 000000000..87504454d --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch03-probability-information-theory.md @@ -0,0 +1,67 @@ +# Chapter 3: Probability and Information Theory + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/prob.html + +## Core Idea + +Deep learning models are probability distributions, and losses are log-likelihoods. This chapter +installs the vocabulary — random variables, the common distributions, expectation, Bayes' rule, +entropy, KL divergence, cross-entropy — that makes "why this loss?" answerable rather than +conventional. + +## Key Concepts + +- **Frequentist vs Bayesian probability**: rates of events vs degrees of belief. Deep learning + uses the Bayesian reading for parameters and the frequentist one for evaluation, mostly + without saying so. +- **Marginal / conditional / chain rule**: the algebra every graphical model in Ch 16 runs on. +- **Common distributions**: Bernoulli, categorical, Gaussian, exponential, Laplace, Dirac, + empirical, and mixtures. Each corresponds to an output layer you will actually build. +- **Self-information and Shannon entropy**: surprise, and expected surprise. +- **KL divergence**: asymmetric. D(p‖q) ≠ D(q‖p), and the asymmetry decides whether your fitted + model covers all modes or concentrates on one. +- **Cross-entropy**: H(p,q) = H(p) + D(p‖q). Minimizing cross-entropy over q is minimizing KL, + because H(p) is constant in q — this is why classification uses it. +- **Structured probabilistic models**: factorization of a joint into conditionals over a graph. + +## Mental Models + +- Read every loss as a **negative log-likelihood** under an assumed output distribution: MSE is + a Gaussian with fixed variance, cross-entropy is a categorical, MAE is a Laplace. If you know + the assumed distribution, you know when the loss is wrong for your data. +- Use KL **direction** as a design lever: forward KL (data ‖ model) is mode-covering and gives + blurry averages; reverse KL (model ‖ data) is mode-seeking and gives sharp but partial fits. + This one fact explains most of Ch 19 and 20. +- Treat softmax as **exp-then-normalize on logits**, and remember from Ch 4 that it must be + computed in a shift-stabilized form. + +## Anti-patterns + +- **Choosing MSE for a bounded or count-valued target** — you have assumed a Gaussian on data + that is not Gaussian, and the residual structure will tell you so. +- **Reading KL as a distance**: it is not symmetric and does not satisfy the triangle inequality. +- **Interpreting softmax outputs as calibrated probabilities** without checking calibration — + the chapter's math does not promise calibration, and modern networks are typically + overconfident (Guo et al. 2017, post-dating the book). + +## What changed after 2016 + +The probabilistic core is unchanged. Two additions matter: the calibration literature (deep +networks are systematically overconfident; temperature scaling is the cheap fix), and the rise +of losses that are *not* clean log-likelihoods — contrastive/InfoNCE objectives, and preference +losses such as DPO. The chapter's "every loss is a likelihood" framing needs that caveat now. +**Confidence: high** for calibration; **high** for the contrastive family being outside the +chapter's scope. + +## Key Takeaways + +1. State the output distribution before choosing the loss; the loss follows from it. +2. When a generative model looks blurry, suspect forward KL; when it looks mode-collapsed, + suspect reverse KL. +3. Check calibration separately from accuracy — the book does not, and the gap is real. + +## Connects To + +- **Ch 5**: maximum likelihood as the estimator that justifies these losses. +- **Ch 16–19**: graphical models, sampling, and variational inference all run on this algebra. +- **Ch 20**: the KL asymmetry decides GAN vs VAE failure modes. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch04-numerical-computation.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch04-numerical-computation.md new file mode 100644 index 000000000..26ee94073 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch04-numerical-computation.md @@ -0,0 +1,66 @@ +# Chapter 4: Numerical Computation + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/numerical.html + +## Core Idea + +Real arithmetic on finite hardware fails in specific, predictable ways — underflow, overflow, +ill-conditioning — and gradient-based optimization inherits every one of them. This is the +shortest chapter with the highest debugging payoff. + +## Key Concepts + +- **Underflow / overflow**: numbers rounding to zero (then dividing) or exceeding representable + range (then becoming inf/NaN). +- **Stabilized softmax**: subtract the max logit before exponentiating; mathematically identical, + numerically survivable. `log_softmax` exists for the same reason. +- **Conditioning**: how much a function's output moves for small input moves; for a matrix, the + condition number from Ch 2. +- **Gradient descent** and the first-order Taylor picture; **critical points**: minima, maxima, + saddles. +- **Jacobian and Hessian**; second-order Taylor expansion, and the optimal step size implied by + curvature. +- **Newton's method** and why it is not the default in deep learning: the Hessian is n×n in the + parameter count. +- **Constrained optimization / KKT**: the framing that makes Ch 7's norm penalties readable as + constraints. + +## Mental Models + +- Read a **NaN in the loss** as a numerics report first and a modelling bug second: check + log(0), division by a near-zero denominator, exp of a large logit, and an exploding gradient + in that order. +- Think of the Hessian's **eigenvalue spread as the terrain**: a large condition number is a + narrow ravine, and the largest safe step size is set by the largest eigenvalue while progress + is set by the smallest. +- Prefer the **log-domain** whenever probabilities are multiplied — this is the single highest + yield habit in this chapter. + +## Anti-patterns + +- **Hand-rolling softmax or cross-entropy** in a training loop instead of using the fused, + stabilized primitive. +- **Blaming the learning rate for every divergence**: ill-conditioning produces the same symptom + and does not respond to the same fix. +- **Reaching for a second-order optimizer** in a model with millions of parameters without + understanding the memory cost. + +## What changed after 2016 + +Mixed-precision training (fp16 with loss scaling; then bf16, which trades mantissa bits for +exponent range specifically to avoid these failures) made this chapter's content a daily +operational concern rather than a background caution. Gradient clipping became standard practice +for transformer training. **Confidence: high.** + +## Key Takeaways + +1. Work in log-space by default for anything probabilistic. +2. When training diverges, separate "step too large" from "problem ill-conditioned" — they need + different fixes. +3. Understand KKT well enough to read weight decay as a constraint; Ch 7 assumes it. + +## Connects To + +- **Ch 8**: every optimization difficulty named here recurs there at scale. +- **Ch 7**: the constrained-optimization view of regularization. +- **scripts/training_diagnostics.py**: mechanizes the NaN/divergence triage above. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch05-machine-learning-basics.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch05-machine-learning-basics.md new file mode 100644 index 000000000..12126a4d3 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch05-machine-learning-basics.md @@ -0,0 +1,66 @@ +# Chapter 5: Machine Learning Basics + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/ml.html + +## Core Idea + +Everything after this chapter is a special case of it: a task, a performance measure, an +experience, a capacity choice, and the generalization gap that capacity choice produces. If you +read one chapter of Part I, read this one. + +## Key Concepts + +- **Task / performance measure / experience (T, P, E)**: the definition that forces you to name + the metric before the model. +- **Capacity**: the range of functions a learner can express. Representational capacity vs + *effective* capacity (what the optimizer actually reaches). +- **Underfitting / overfitting** and the classical **U-shaped** generalization-error curve. +- **Bias–variance decomposition**: expected error splits into bias², variance, and irreducible + noise. Regularization trades variance for bias. +- **No Free Lunch theorem**: averaged over *all* data-generating distributions, every algorithm + ties. Therefore all progress comes from priors matched to the distributions we actually face. +- **Regularization**: any change intended to reduce generalization error but not training error. +- **Maximum likelihood estimation** and its **MAP** counterpart; consistency and efficiency. +- **Hyperparameters and validation sets**: never tune on test. +- **Curse of dimensionality, local constancy, manifold hypothesis**: why nearest-neighbour-style + priors fail and why deep models assume data concentrates near a low-dimensional manifold. + +## Mental Models + +- Ask "**what is the prior?**" before "what is the model?" — No Free Lunch says the prior is + where all the leverage is. +- Read regularization as **moving mass in the bias–variance budget**, so that "add dropout" and + "get more data" are alternative purchases of the same thing. +- Treat the **train/val gap** as your primary instrument: gap small + error high = underfit + (capacity or optimization); gap large = overfit (regularization or data). + +## Anti-patterns + +- **Tuning against the test set** — including "just peeking once." +- **Comparing models on different splits**, or reporting a single seed for a small dataset. +- **Believing the U-curve unconditionally.** See below: this is the one place where the 2016 + text is now known to be incomplete. + +## What changed after 2016 + +**This is the chapter's one substantive correction.** The classical U-shaped capacity curve is +not the whole picture. In the heavily overparameterized regime, test error can fall again past +the interpolation threshold — "double descent" (Belkin et al. 2019; Nakkiran et al. 2020), which +post-dates the book. Modern large models routinely sit in a regime the 2016 framing predicts +should overfit catastrophically and does not. The bias–variance decomposition remains correct as +algebra; the *managerial advice* "reduce capacity when you overfit" is no longer the only right +move. **Confidence: high** — double descent is widely replicated. Practical consequence: try +"more data / more regularization / train longer" before "smaller model." + +## Key Takeaways + +1. Write down T, P, E in one sentence before writing any model code. +2. Use the train/val gap to choose your next action; do not guess. +3. Hold the U-curve loosely — check whether you are past the interpolation threshold before + shrinking a model that overfits. + +## Connects To + +- **Ch 7**: every regularizer named here, in depth. +- **Ch 11**: this chapter's diagnostics turned into a workflow. +- **scripts/capacity_planner.py**: mechanizes the gap-to-action rule, double descent included. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch06-deep-feedforward-networks.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch06-deep-feedforward-networks.md new file mode 100644 index 000000000..3d33314c7 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch06-deep-feedforward-networks.md @@ -0,0 +1,61 @@ +# Chapter 6: Deep Feedforward Networks + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/mlp.html + +## Core Idea + +An MLP is a composed chain of parametric functions trained by gradient descent, where the choice +of output unit follows from the assumed output distribution and the choice of hidden unit follows +from gradient behaviour. Backpropagation is not learning; it is the chain rule scheduled well. + +## Key Concepts + +- **Universal approximation**: one sufficiently wide hidden layer can approximate any continuous + function on a compact set. It says nothing about learnability, sample efficiency, or the size + required — depth is how you get the same function with far fewer units. +- **Output units follow the distribution**: linear + MSE → Gaussian; sigmoid + binary + cross-entropy → Bernoulli; softmax + cross-entropy → categorical; mixture density → multimodal. +- **Hidden units**: ReLU as the sane default; leaky/parametric ReLU, ELU, GELU/Swish (post-2016) + for smoothness; sigmoid/tanh only where saturation is desired (gates). +- **Saturation**: sigmoid/tanh gradients vanish in the tails, which is why they are poor hidden + units and fine as gates. +- **Backpropagation**: reverse-mode automatic differentiation over the computation graph. Cost is + roughly one forward pass, memory is the stored activations. +- **Computation graph / autodiff**: the abstraction every framework implements. Forward mode is + cheap in inputs; reverse mode is cheap in outputs — losses are scalar, hence reverse. + +## Mental Models + +- Pick the output unit from the **likelihood** (Ch 3), then pick the loss as its negative log. + Every "which loss?" question reduces to "which distribution?" +- Read **ReLU's advantage as gradient preservation**, not nonlinearity per se: it is piecewise + linear, so the gradient through an active unit is exactly 1. +- Treat activation memory as the **real** cost of depth in training: the backward pass needs the + forward activations, which is why checkpointing trades compute for memory. + +## Anti-patterns + +- **Citing universal approximation to justify a shallow model** — the theorem allows an + exponentially wide layer, which is not an engineering plan. +- **Sigmoid hidden layers** in a deep stack. +- **Pairing a sigmoid output with MSE**: gradients vanish exactly where the model is most wrong. + Use cross-entropy. + +## What changed after 2016 + +Smooth activations (GELU, SiLU/Swish) became the default in transformers; gated variants +(GLU, SwiGLU) are now standard in large language model feedforward blocks. Residual connections +(He et al. 2015) are mentioned here only in passing but became the structural default for every +deep stack. Reverse-mode autodiff is unchanged. **Confidence: high.** + +## Key Takeaways + +1. Derive the loss from the output distribution rather than picking it by habit. +2. Default to ReLU-family hidden units; reserve saturating units for gates. +3. Budget activation memory as a first-class constraint, not an implementation detail. + +## Connects To + +- **Ch 3**: the distributions that determine output units. +- **Ch 8**: why gradient preservation matters at depth. +- **scripts/model_arithmetic.py**: parameter, FLOP and activation-memory accounting for a stack. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch07-regularization.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch07-regularization.md new file mode 100644 index 000000000..cd2cbc3b2 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch07-regularization.md @@ -0,0 +1,66 @@ +# Chapter 7: Regularization for Deep Learning + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/regularization.html + +## Core Idea + +Regularization is anything that trades training error for generalization error, and this chapter +is the catalogue: norm penalties, data augmentation, noise, early stopping, parameter sharing, +sparsity, ensembling, dropout, and adversarial training — with their equivalences made explicit. + +## Frameworks Introduced + +- **L2 / weight decay**: shrinks parameters along low-curvature Hessian directions. Under a + quadratic approximation, it damps each eigen-direction by λᵢ/(λᵢ+α) — directions the loss does + not care about get pulled to zero. +- **L1**: yields genuine sparsity; equivalent to a Laplace prior on parameters. +- **Norm penalty as constraint**: via KKT (Ch 4), a penalty is a soft version of a norm ball. + Explicit projection (max-norm) is sometimes better behaved. +- **Dataset augmentation**: the most reliable regularizer when the invariance is real. Choose + transformations that preserve the label — and check that they do. +- **Noise injection**: on inputs (≈ L2 penalty for some models), on weights (encourages flat + minima), on labels (label smoothing). +- **Early stopping**: the cheapest regularizer; under a quadratic approximation it is + approximately equivalent to L2 with a strength set by the number of steps. +- **Parameter tying / sharing**: the strongest form — it removes parameters instead of penalizing + them. Convolution is parameter sharing (Ch 9). +- **Bagging and ensembles**: variance reduction by averaging independently trained models. +- **Dropout**: approximate ensembling over exponentially many sub-networks at the cost of one. +- **Adversarial training**: penalizing sensitivity to worst-case local perturbation. + +## Mental Models + +- Order regularizers by **cost per unit of gap closed**: more real data > augmentation > early + stopping > weight decay > dropout > architecture surgery. +- Read weight decay as **"forget the directions the data does not constrain"** — that is exactly + what the eigen-analysis says. +- Treat dropout as an **ensemble**, so its interaction with batch normalization (train/test + statistics mismatch) is expected rather than surprising. + +## Anti-patterns + +- **Stacking every regularizer at once** and then tuning — you cannot attribute the effect. +- **Augmenting with label-destroying transforms** (horizontal flip on digits, aggressive crops on + fine-grained classes). +- **Dropout inside a residual transformer block plus batch norm plus heavy weight decay** without + measuring; modern stacks regularize far more lightly than 2016 practice. + +## What changed after 2016 + +Weight decay and L2 were shown to be *not* equivalent under adaptive optimizers, which is why +**AdamW** (Loshchilov & Hutter 2017/2019) decouples them — this post-dates the book and is now +the default. Dropout largely left large-scale vision and language models, displaced by +normalization, augmentation and sheer data volume. Label smoothing and stochastic depth became +common. **Confidence: high** for AdamW; **high** for the decline of dropout at scale. + +## Key Takeaways + +1. Add regularizers one at a time and measure the gap after each. +2. Use decoupled weight decay (AdamW) rather than L2-in-the-loss with an adaptive optimizer. +3. Prefer parameter sharing over penalties when a real invariance exists. + +## Connects To + +- **Ch 5**: the bias–variance budget these all spend from. +- **Ch 8**: optimizer choice changes what weight decay means. +- **Ch 11**: which regularizer to reach for, given a measured gap. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch08-optimization.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch08-optimization.md new file mode 100644 index 000000000..11b472eb7 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch08-optimization.md @@ -0,0 +1,67 @@ +# Chapter 8: Optimization for Training Deep Models + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/optimization.html + +## Core Idea + +Training is not pure optimization: you minimize an empirical surrogate to reduce a risk you +cannot measure, on a non-convex landscape, with noisy gradients. The chapter separates the +difficulties (ill-conditioning, saddles, cliffs, long-term dependencies, poor correspondence +between local and global structure) from the algorithms that address them. + +## Frameworks Introduced + +- **Empirical risk minimization and its surrogates**: you optimize a differentiable proxy, not + the metric you report. +- **Minibatch SGD**: gradient noise scales roughly with 1/√batch, so bigger batches buy accuracy + in the gradient at linear cost — a poor trade past a point. +- **Ill-conditioning**: the dominant obstacle in practice; gradient norm can *grow* while the loss + stalls. +- **Saddle points, not local minima**: in high dimensions, critical points are overwhelmingly + saddles. This reframes the folklore fear of local minima. +- **Cliffs and exploding gradients** → **gradient clipping**. +- **Momentum** and **Nesterov momentum**: accumulate a velocity to cross ravines. +- **Initialization**: Xavier/Glorot and He scaling to keep activation and gradient variance + roughly constant with depth. Initialization is an algorithm, not a detail. +- **Adaptive methods**: AdaGrad (decaying), RMSProp, **Adam** (RMSProp + momentum + bias + correction). +- **Second-order methods**: Newton, conjugate gradient, BFGS/L-BFGS — and why they rarely survive + contact with minibatch noise and parameter counts. +- **Batch normalization**: reparameterization that stabilizes the scale of layer inputs. +- **Curriculum learning / coordinate descent / Polyak averaging**: the meta-strategies. + +## Mental Models + +- Diagnose by **what the gradient is doing**: norm exploding = cliff, clip. Norm large but loss + flat = ill-conditioning, use momentum/adaptive/normalization. Norm ~0 with high loss = dead + units or saturation. +- Treat **learning-rate schedule as a first-class hyperparameter** — usually more important than + the choice among SGD/Adam variants. +- Read batch norm as **making the loss surface better conditioned**; whether it does so by + reducing "internal covariate shift" is contested (Santurkar et al. 2018, post-2016). + +## Anti-patterns + +- **Fearing local minima**: spend the debugging effort on conditioning and learning rate. +- **Tuning the optimizer before the learning rate.** +- **Batch norm with tiny batches** — the batch statistics become noise. Use group/layer norm. + +## What changed after 2016 + +The largest delta in Part II. **AdamW** replaced Adam+L2. Learning-rate **warmup plus cosine +decay** became standard for transformers. **LayerNorm** (and RMSNorm) displaced batch norm in +sequence models; pre-norm residual placement displaced post-norm for deep stacks. Large-batch +training with LARS/LAMB, and the linear-scaling-rule literature, arrived after the book. +**Confidence: high.** + +## Key Takeaways + +1. Tune learning rate and schedule first; optimizer family second. +2. Read gradient-norm behaviour before changing the architecture. +3. Use He/Xavier-style initialization deliberately — bad init is a common silent failure. + +## Connects To + +- **Ch 4**: conditioning, Hessians, and step size. +- **Ch 10**: exploding/vanishing gradients as the sequence-model version of these problems. +- **scripts/training_diagnostics.py**: this chapter's triage as an executable decision tree. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch09-convolutional-networks.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch09-convolutional-networks.md new file mode 100644 index 000000000..86e59aed8 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch09-convolutional-networks.md @@ -0,0 +1,66 @@ +# Chapter 9: Convolutional Networks + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/convnets.html + +## Core Idea + +Convolution is three ideas at once — sparse interactions, parameter sharing, and equivariance to +translation — and each is a prior about grid-structured data. Pooling adds approximate invariance +to small translations. The architecture is a statement about the data, not a trick. + +## Frameworks Introduced + +- **Sparse interactions**: each output depends on a small receptive field, so cost drops from + O(m·n) to O(k·n). +- **Parameter sharing**: one kernel is reused at every position — the strongest regularizer in + Ch 7's catalogue, applied structurally. +- **Equivariance to translation**: shift the input, the feature map shifts. Convolution is *not* + equivariant to rotation or scale — a fact that motivates augmentation. +- **Pooling**: max/average pooling gives local invariance and downsampling; it is a prior that + small position changes should not matter. +- **Variants**: valid/same/full padding, strided convolution, dilated convolution, tiled + convolution, locally-connected layers (no sharing), transposed convolution for upsampling. +- **Convolution as an infinitely strong prior**: the chapter's sharpest framing — a conv layer is + a fully-connected layer with hard constraints on its weights. +- **Structured outputs**: dense prediction (segmentation) rather than one label per image. +- **Efficiency**: FFT-based and separable convolutions. + +## Mental Models + +- Ask "**is the statistic I need position-invariant?**" If yes, convolve. If not (a face-aligned + dataset, a tabular grid with meaningful coordinates), the prior is wrong and locally-connected + or attention layers may fit better. +- Compute **receptive field** deliberately: it must cover the evidence needed for the decision. + Depth, stride and dilation are three ways to buy it, with different costs. +- Treat pooling as **throwing away location on purpose**; when location is the answer + (segmentation, detection), pool less and use dilation or skip connections. + +## Anti-patterns + +- **Using convolution on data with no spatial/temporal locality** (arbitrary tabular columns) — + the sharing prior is simply false there. +- **Ignoring receptive field** and then adding parameters to fix an underfitting model. +- **Aggressive pooling in a dense-prediction task.** + +## What changed after 2016 + +Residual networks (2015) are treated only briefly here but became the default. Since then: +depthwise-separable convolutions (MobileNet/Xception), EfficientNet-style compound scaling, +and — most importantly — **Vision Transformers** (Dosovitskiy et al. 2020), which discard the +convolutional prior in favour of data plus attention, and win at scale while ConvNets remain +competitive at smaller data sizes (ConvNeXt, 2022, closed much of the gap). The chapter's +argument that the conv prior is a *bet on the data* is exactly what ViT's data-hunger confirms. +**Confidence: high.** + +## Key Takeaways + +1. Justify convolution by the invariance you actually believe in. +2. Size the receptive field before adding depth for its own sake. +3. Expect the conv prior to pay off most when data is limited — this is the modern boundary + between ConvNets and ViTs. + +## Connects To + +- **Ch 7**: parameter sharing as structural regularization. +- **Ch 12**: computer-vision applications built on this. +- **references/book_to_2026_delta.md**: the ViT/ConvNeXt line. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch10-sequence-modeling.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch10-sequence-modeling.md new file mode 100644 index 000000000..3f453cb06 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch10-sequence-modeling.md @@ -0,0 +1,70 @@ +# Chapter 10: Sequence Modeling — Recurrent and Recursive Nets + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/rnn.html + +## Core Idea + +Recurrence shares parameters across time the way convolution shares them across space, which lets +one model handle variable-length sequences. The price is that gradients must traverse many +multiplicative steps, producing the vanishing/exploding gradient problem — and gated +architectures exist to pay it. + +## Frameworks Introduced + +- **Unfolding the computational graph**: a recurrent definition becomes a deep feedforward graph + with tied weights. +- **BPTT (backpropagation through time)** and **truncated BPTT**. +- **Teacher forcing**: train on ground-truth previous tokens; note the train/inference mismatch + (exposure bias) it creates. +- **Vanishing and exploding gradients**: repeated multiplication by the recurrent Jacobian; the + spectral radius decides which. +- **Gradient clipping** for the exploding half; **gating** for the vanishing half. +- **LSTM** (input/forget/output gates plus a cell with an additive path) and **GRU** (a + two-gate simplification). The additive cell path is the mechanism — it makes the gradient path + through time approximately linear. +- **Bidirectional RNNs**; **encoder–decoder / sequence-to-sequence** with a fixed-size context. +- **Attention** appears here as the fix for the encoder–decoder bottleneck. +- **Deep RNNs, recursive (tree-structured) nets, echo state networks, leaky units, skip + connections through time.** + +## Mental Models + +- Read gating as **learned, data-dependent memory management**: the forget gate decides how long + the additive highway stays open. +- Treat the fixed-size context vector in vanilla seq2seq as a **bottleneck** — the whole reason + attention was invented. +- Expect **exposure bias** whenever you teacher-force: the model has never seen its own mistakes + during training. + +## Anti-patterns + +- **Reaching for an RNN by default in 2026** for a task where a transformer is the standard + baseline and the sequence fits in context. +- **Training a long-sequence RNN without gradient clipping.** +- **Ignoring the train/inference mismatch** in autoregressive generation. + +## What changed after 2016 + +**The largest single delta in the book.** Attention, described here as an enhancement to +recurrent seq2seq, became the whole architecture with *Attention Is All You Need* (Vaswani et al. +2017) — one year after publication. Transformers replaced RNNs for essentially all large-scale +sequence work: parallel training over sequence positions, direct O(1) path length between any two +tokens, and much better scaling. What survived: gradient clipping, teacher forcing, exposure bias, +and the vanishing/exploding analysis. What returned: linear-time recurrent architectures +(S4/Mamba-style state-space models, 2021–2024) as a long-context alternative, which makes this +chapter's material relevant again rather than obsolete. **Confidence: high.** + +**Read this chapter for the gradient-flow analysis, not for the architecture recommendation.** + +## Key Takeaways + +1. Learn the vanishing/exploding analysis here — it explains residual connections, LayerNorm + placement, and state-space models alike. +2. Clip gradients whenever a recurrence is in the graph. +3. Treat this chapter's architecture advice as historical; treat its diagnosis as current. + +## Connects To + +- **Ch 8**: the same optimization pathologies, in the time dimension. +- **Ch 12**: NLP and speech applications built on these. +- **references/book_to_2026_delta.md**: the transformer displacement, in detail. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch11-practical-methodology.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch11-practical-methodology.md new file mode 100644 index 000000000..8c6e5b4f2 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch11-practical-methodology.md @@ -0,0 +1,63 @@ +# Chapter 11: Practical Methodology + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/guidelines.html + +## Core Idea + +Knowing many algorithms matters less than knowing which one to reach for given a measurement. +This chapter is a workflow: pick a metric, build an end-to-end baseline fast, then let +instrumentation — not intuition — decide whether to add data, capacity, or regularization. + +## Frameworks Introduced + +- **The design process**: (1) determine goals — the error metric and the target value; (2) build + a working end-to-end pipeline early; (3) instrument it to find bottlenecks; (4) change one + thing at a time based on measurement. +- **Choose the metric before the model**: accuracy, precision/recall, F-score, PR/ROC curves, + coverage. Name the target value and where it came from. +- **Sensible baselines**: pick the standard architecture and optimizer for the data type before + inventing anything. +- **More data or a bigger model?** — the decision procedure: if training error is high, the model + or the optimization is the bottleneck (data will not help). If training error is low and test + error is high, gather more data or regularize. +- **Hyperparameter tuning**: manual (understand what each knob does to effective capacity), + **grid search** (poor scaling in dimensions), **random search** (better — it does not waste + trials on unimportant dimensions; Bergstra & Bengio 2012), and model-based/Bayesian. +- **Debugging strategies**: visualize the model's actual predictions and worst cases; fit a tiny + subset to zero training error; compare backprop against numerical derivatives; monitor + activation and gradient histograms. + +## Mental Models + +- Use the **train-error-first rule** as the single most valuable heuristic in the book: high + training error means "do not collect data yet." +- Treat "**can it overfit 20 examples?**" as the smoke test that separates a bug from a modelling + limitation. If it cannot, you have a bug. +- Prefer **random search over grid search** whenever the number of hyperparameters exceeds ~2. + +## Anti-patterns + +- **Tuning many things per experiment** — you learn nothing attributable. +- **Collecting more data to fix underfitting.** +- **Optimizing a proxy metric** whose relationship to the real objective was never checked. +- **Skipping the end-to-end pipeline** in favour of perfecting one component. + +## What changed after 2016 + +The workflow is the most durable material in the book and is now the backbone of MLOps practice. +Additions since: experiment tracking as standard tooling, seeded reproducibility expectations, +Hyperband/ASHA for early-stopping-based search (post-2016), and — for large models — scaling laws +used to *predict* the return on more data or parameters instead of testing empirically at full +size. **Confidence: high.** + +## Key Takeaways + +1. Write the metric and its target number before writing model code. +2. Ask "is training error high?" before every data or capacity decision. +3. Change one thing per experiment and log it. + +## Connects To + +- **Ch 5**: the underfit/overfit framing this operationalizes. +- **Ch 7 / Ch 8**: the two toolboxes the workflow selects from. +- **scripts/training_diagnostics.py**: this decision tree, executable. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch12-applications.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch12-applications.md new file mode 100644 index 000000000..31fe57c7a --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch12-applications.md @@ -0,0 +1,62 @@ +# Chapter 12: Applications + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/applications.html + +## Core Idea + +How deep learning was actually deployed circa 2016: large-scale implementation (GPUs, +distributed training, model compression), then computer vision, speech recognition, and NLP. +This is the most time-dated chapter in the book — read the *systems* half, discount the +*state-of-the-art* half. + +## Frameworks Introduced + +- **Large-scale implementation**: GPU data parallelism, model parallelism, asynchronous SGD + (Hogwild-style), parameter servers. +- **Model compression**: distillation, quantization, pruning — for inference cost. +- **Dynamic structure / conditional computation**: cascades and gating so that not every input + pays the full cost. +- **Preprocessing in vision**: contrast normalization, whitening, dataset augmentation. +- **Speech**: the acoustic-model pipeline and its shift from GMM-HMM to deep networks. +- **NLP**: n-gram models, word embeddings and the curse of dimensionality over vocabularies, + hierarchical softmax and sampling-based approximations for large output vocabularies, + neural machine translation. +- **Recommender systems** and the exploration/exploitation problem. +- **Knowledge representation and relational reasoning.** + +## Mental Models + +- Separate **capability claims** (perishable) from **systems constraints** (durable). Memory + bandwidth, batch efficiency, and inference cost still shape architecture choices. +- Read the large-vocabulary softmax section as an instance of the general pattern: **when the + normalizing sum is expensive, approximate it** — the same problem Ch 18 attacks head-on. +- Treat conditional computation as the ancestor of **Mixture-of-Experts** routing. + +## Anti-patterns + +- **Citing this chapter's benchmark numbers or SOTA claims** — they are a decade old. +- **Copying its NLP pipeline**: subword tokenization (BPE/SentencePiece) and pretrained + transformers replaced nearly all of it. + +## What changed after 2016 + +Almost everything at the application layer. Speech moved to end-to-end CTC/attention models and +then to large self-supervised encoders. NLP moved to pretrained transformers (BERT 2018, GPT +family), with subword tokenization and full-softmax over ~30k–200k subwords making hierarchical +softmax largely unnecessary. Vision moved to self-supervised pretraining and ViTs. Distillation, +quantization and pruning grew into a mature inference-optimization discipline. Conditional +computation matured into sparse MoE. **Confidence: high.** + +## Key Takeaways + +1. Read this chapter for the systems reasoning and the approximation techniques, not for what is + state of the art. +2. When your output vocabulary or normalizing constant is huge, recognize it as the recurring + partition-function problem. +3. Plan inference cost as an architecture constraint from the start. + +## Connects To + +- **Ch 18**: the partition-function problem in its general form. +- **Ch 9 / Ch 10**: the architectures being applied here. +- **references/book_to_2026_delta.md**: what replaced each application pipeline. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch13-linear-factor-models.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch13-linear-factor-models.md new file mode 100644 index 000000000..42d91ecaf --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch13-linear-factor-models.md @@ -0,0 +1,60 @@ +# Chapter 13: Linear Factor Models + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/linear_factors.html + +## Core Idea + +The simplest generative models: sample latent factors from a simple prior, map them linearly to +observations, add noise. Probabilistic PCA, factor analysis, ICA, sparse coding and slow feature +analysis are all this one template with different priors — and they are the scaffolding for +everything in Chapters 14–20. + +## Key Concepts + +- **The template**: h ~ p(h); x = Wh + b + noise. Change p(h) and the noise model, get a + different named method. +- **Factor analysis**: Gaussian latent prior, diagonal (per-feature) observation noise. +- **Probabilistic PCA**: factor analysis with isotropic noise; recovers PCA as noise → 0. +- **Independent component analysis (ICA)**: non-Gaussian independent latents — this is what makes + the factors identifiable, which Gaussian models cannot be (any rotation fits equally well). +- **Sparse coding**: a heavy-tailed (Laplace/Cauchy) prior; inference is an optimization, not a + closed form, which makes encoding expensive. +- **Slow feature analysis**: a prior that useful factors change slowly over time. +- **Manifold interpretation of PCA**: the model concentrates probability near a linear subspace. + +## Mental Models + +- Read each model as **a prior choice**, and read the prior as the assumption that buys + identifiability. Gaussian latents are rotation-invariant, so a Gaussian model cannot tell you + *which* factors — only which subspace. +- Treat sparse coding as the moment where **inference becomes iterative**; that cost is precisely + what autoencoders (Ch 14) amortize with a learned encoder. +- Use these as the **linear baseline** for any representation-learning claim: if a linear factor + model matches your deep encoder, the depth is not earning its cost. + +## Anti-patterns + +- **Skipping this chapter and starting at Ch 20** — VAEs read as arbitrary machinery without the + latent-variable template established here. +- **Expecting interpretable factors from a Gaussian-latent model.** + +## What changed after 2016 + +The template survived; the emphasis moved. Nonlinear ICA identifiability results (Hyvärinen et +al., 2016–2020) clarified when latent factors are recoverable at all, and the disentanglement +literature — notably Locatello et al. (2019) — showed that unsupervised disentanglement is +impossible without inductive biases or supervision, which is a formal statement of this +chapter's identifiability point. Sparse coding returned as a tool for interpreting neural +networks (sparse autoencoders over LLM activations, 2023–2024). **Confidence: high.** + +## Key Takeaways + +1. Name the latent prior when you propose any generative model; it determines what is learnable. +2. Use a linear factor model as the baseline before claiming a deep representation helps. +3. Remember that identifiability, not fit quality, is what non-Gaussian priors buy. + +## Connects To + +- **Ch 14**: autoencoders amortize the inference these models do by optimization. +- **Ch 15**: what makes a representation good. +- **Ch 20**: the VAE is this template with a nonlinear decoder and amortized inference. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch14-autoencoders.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch14-autoencoders.md new file mode 100644 index 000000000..c05d17652 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch14-autoencoders.md @@ -0,0 +1,64 @@ +# Chapter 14: Autoencoders + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/autoencoders.html + +## Core Idea + +An autoencoder learns to copy its input imperfectly, and the constraint that prevents perfect +copying is where the learning happens. Undercompleteness, sparsity, denoising and contraction are +four different ways to impose that constraint — each defines a different notion of "useful". + +## Frameworks Introduced + +- **Undercomplete autoencoder**: bottleneck smaller than the input. With linear units and MSE it + recovers PCA's subspace. +- **Regularized autoencoders**: capacity can exceed the input dimension as long as something else + prevents identity — this is the chapter's key move. +- **Sparse autoencoder**: penalize code activation; interpretable as a latent prior (Ch 13). +- **Denoising autoencoder (DAE)**: corrupt the input, reconstruct the clean version. The learned + map estimates the *score* — it points back toward the data manifold, which is the direct + ancestor of score-based diffusion models. +- **Contractive autoencoder (CAE)**: penalize the Jacobian norm of the encoder, so the + representation resists input perturbation except along the manifold. +- **Manifold learning view**: the encoder is sensitive along tangent directions of the data + manifold and insensitive orthogonal to it. +- **Stochastic encoders and decoders**: autoencoders as p(h|x) and p(x|h). +- **Predictive sparse decomposition**; **applications**: dimensionality reduction, semantic + hashing, pretraining. + +## Mental Models + +- Ask "**what stops it from learning the identity?**" — the answer names the inductive bias, and + a model with no answer learns nothing useful. +- Read a DAE's learned vector field as **pointing uphill in density**. Once you see that, + diffusion models are the same idea run at many noise levels. +- Treat the encoder as **amortized inference**: it replaces the per-example optimization of + sparse coding with one forward pass. + +## Anti-patterns + +- **An overcomplete autoencoder with no regularizer** — it can and will learn a copy. +- **Judging representation quality by reconstruction error**: low reconstruction error can mean + the code memorized the input, which is the opposite of useful. + +## What changed after 2016 + +The denoising idea became the foundation of modern generative modelling: denoising score matching +(Vincent 2011) → score-based models (Song & Ermon 2019) → DDPM (Ho et al. 2020) → the diffusion +family. Greedy layerwise autoencoder pretraining, presented here as a live technique, disappeared +— displaced first by better initialization/normalization and then by large-scale supervised and +self-supervised pretraining. The VAE overshadowed the deterministic autoencoders for generation, +while masked autoencoders (He et al. 2021) revived the corruption idea for vision pretraining. +**Confidence: high.** + +## Key Takeaways + +1. Always name the constraint that prevents identity before training an autoencoder. +2. Do not use reconstruction error as a representation-quality metric; evaluate downstream. +3. Learn the denoising/score connection here — it is the cheapest on-ramp to diffusion models. + +## Connects To + +- **Ch 13**: sparse coding, amortized. +- **Ch 15**: what a "good" representation means. +- **Ch 20**: VAEs, and the diffusion line that grew from denoising. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch15-representation-learning.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch15-representation-learning.md new file mode 100644 index 000000000..29d46d480 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch15-representation-learning.md @@ -0,0 +1,64 @@ +# Chapter 15: Representation Learning + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/representation.html + +## Core Idea + +What makes one representation better than another, and how do you get one without labels? The +chapter names the properties worth wanting — distributed, disentangled, capturing causal factors +— and the transfer mechanisms that let a representation earned on one task pay off on another. + +## Frameworks Introduced + +- **Greedy layer-wise unsupervised pretraining**: the historically important technique that + restarted the field in 2006; the chapter is already candid that its value had shrunk by 2016. +- **Transfer learning and domain adaptation**: reuse a representation across tasks or + distributions; **one-shot / zero-shot learning** as the limiting cases. +- **Distributed representations**: n binary features describe 2ⁿ regions, versus n regions for a + one-hot/symbolic code. This exponential advantage is the central argument for learned features. +- **Disentangled factors**: separate underlying causes onto separate directions. +- **Exponential gains from depth**: deeper composition of distributed features again multiplies + expressible structure. +- **Causal factors and semi-supervised learning**: unsupervised learning helps supervised learning + exactly when p(x) and p(y|x) share structure — if the factors generating x include y's causes. +- **Regularization priors that define good representations**: smoothness, linearity, multiple + explanatory factors, hierarchy, sparsity, simplicity of factor dependencies, shared factors + across tasks, manifolds, temporal/spatial coherence. + +## Mental Models + +- Judge a representation by **what becomes linearly separable** in it — that is the operational + version of "good features." +- Use the **shared-cause test** to predict whether unlabeled data will help: if p(x) tells you + nothing about p(y|x), self-supervision will not rescue a supervised task. +- Read distributed-vs-symbolic as **the reason embeddings beat lookup tables**, and note that the + advantage is combinatorial, not merely empirical. + +## Anti-patterns + +- **Expecting unsupervised pretraining to help unconditionally** — the chapter itself is careful + here, and the 2016-era conclusion (it often does not, for large labeled datasets) was correct + for the methods then available. +- **Claiming disentanglement without a metric or an intervention.** + +## What changed after 2016 + +This chapter aged into relevance rather than out of it. Self-supervised learning became the +dominant paradigm — contrastive methods (SimCLR, MoCo, CLIP), masked prediction (BERT, MAE), and +next-token prediction at scale — vindicating the chapter's core bet while replacing every +specific method it lists. Two corrections: greedy layer-wise pretraining is now purely +historical, and unsupervised disentanglement was proven impossible without inductive bias +(Locatello et al. 2019). Linear-probe evaluation became the standard test of representation +quality. **Confidence: high.** + +## Key Takeaways + +1. Evaluate representations with linear probes and downstream transfer, not reconstruction. +2. Before investing in self-supervision, argue that p(x) and p(y|x) share causes. +3. Treat "disentangled" as a claim requiring an intervention-based test. + +## Connects To + +- **Ch 14**: autoencoders as one route to a representation. +- **Ch 1**: the promise made in the introduction, cashed out here. +- **references/book_to_2026_delta.md**: the self-supervised learning line. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch16-structured-probabilistic-models.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch16-structured-probabilistic-models.md new file mode 100644 index 000000000..d31620374 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch16-structured-probabilistic-models.md @@ -0,0 +1,64 @@ +# Chapter 16: Structured Probabilistic Models for Deep Learning + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/graphical_models.html + +## Core Idea + +A joint distribution over n variables is exponentially large; a graph that encodes conditional +independence makes it tractable. Directed models factor into conditionals, undirected models into +unnormalized potentials divided by a partition function — and that partition function is the bill +Chapters 17–19 spend their pages paying. + +## Frameworks Introduced + +- **The challenge of unstructured modelling**: memory, statistical efficiency, and inference cost + all scale with the number of parameters in a full joint. +- **Directed models (Bayesian networks)**: p(x) = ∏ p(xᵢ | parents(xᵢ)). Normalized by + construction — no partition function. +- **Undirected models (Markov random fields)**: p(x) = (1/Z) ∏ φ_c(x_c) over cliques. Z is the + partition function, and it is a sum over all configurations. +- **Energy-based models**: p(x) ∝ exp(−E(x)). Any positive distribution can be written this way. +- **Separation / d-separation**: reading conditional independence off the graph. +- **Converting between graph types; factor graphs** for disambiguating factorization. +- **Sampling from graphical models**: ancestral sampling (easy, directed) vs Gibbs sampling + (needed for undirected). +- **Structure learning and latent variables**; the **restricted Boltzmann machine (RBM)** as the + worked example. + +## Mental Models + +- Use the rule of thumb: **directed = easy sampling, harder inference with explaining-away; + undirected = natural for mutual constraints, hard normalization.** +- Read an energy function as a **soft constraint set**: low energy where constraints are + satisfied. Design E, and p follows. +- Remember that the partition function is not an inconvenience — it is **the** obstacle that + organizes all of Part III. + +## Anti-patterns + +- **Writing an undirected model and ignoring Z** until training refuses to work. +- **Assuming a graph's missing edge means independence in the data** — it means the *model* + asserts independence. + +## What changed after 2016 + +Deep undirected graphical models (deep Boltzmann machines, RBM stacks) are now largely historical +as generative workhorses. But energy-based modelling did not die: it returned via score matching +and diffusion (which sidestep Z entirely by learning ∇ log p rather than p), and via +energy-based reinterpretations of contrastive learning. Autoregressive factorization — the +directed side of this chapter — became the dominant paradigm through transformer language models, +which are exactly chain-rule factorizations with a huge neural conditional. **Confidence: high.** + +## Key Takeaways + +1. Classify any generative proposal as directed or undirected first; it predicts which problems + you will have. +2. When Z is intractable, look for a formulation that never needs it (score, ratio, or + autoregressive). +3. Read modern LLMs as ancestral sampling from a directed chain — the framing here still applies. + +## Connects To + +- **Ch 17–19**: the three responses to intractability (sample it, approximate Z, approximate the + posterior). +- **Ch 20**: the models built on these foundations. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch17-monte-carlo-methods.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch17-monte-carlo-methods.md new file mode 100644 index 000000000..9a646b2d9 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch17-monte-carlo-methods.md @@ -0,0 +1,60 @@ +# Chapter 17: Monte Carlo Methods + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/monte_carlo.html + +## Core Idea + +When a sum or integral is intractable, estimate it with samples. The estimator is unbiased and +its error shrinks as 1/√n regardless of dimension — which is why sampling survives where +quadrature does not. The difficulty moves from computing the integral to obtaining the samples. + +## Key Concepts + +- **Monte Carlo estimation**: approximate E_p[f(x)] by the sample mean; variance falls as 1/n, + standard error as 1/√n. +- **Importance sampling**: sample from a tractable q and reweight by p/q. Variance depends + entirely on how well q matches p·f — a bad proposal gives an estimator that is unbiased and + useless. +- **Markov chain Monte Carlo (MCMC)**: build a chain whose stationary distribution is the target, + then use its states as (correlated) samples. +- **Gibbs sampling**: resample each variable from its conditional given the rest; the natural + MCMC scheme for graphical models. +- **Burn-in / mixing time**: the chain needs time to forget its initialization and to move + between modes. +- **The mixing problem between separated modes**: with well-separated modes, transition + probability between them is tiny and the chain reports a single mode as if it were everything. +- **Tempering / annealing** as remedies: flatten the distribution so the chain can travel. + +## Mental Models + +- Treat a Monte Carlo estimate as a **measurement with error bars**; report the standard error, + because "the estimate is unbiased" says nothing about whether n was large enough. +- Diagnose a suspiciously confident sampler as a **mixing failure**, not a modelling success. +- Read importance sampling's variance condition as: **you must already know roughly where the + mass is** — this is why proposals matter more than sample counts. + +## Anti-patterns + +- **Reporting MCMC samples without a mixing diagnostic.** +- **Trusting importance weights with huge dynamic range** — an effective sample size of ~1 is + common and invisible unless measured. + +## What changed after 2016 + +The chapter's methods remain textbook-correct, but their role in deep generative modelling +shrank: diffusion models replaced slow MCMC-based sampling with a fixed, finite denoising chain, +and modern generation is dominated by ancestral sampling from autoregressive models. Where MCMC +is still used — Bayesian deep learning, some energy-based models — HMC/NUTS variants dominate +over plain Gibbs. **Confidence: high.** + +## Key Takeaways + +1. Always pair a Monte Carlo estimate with its standard error. +2. Check mixing before believing anything an MCMC sampler tells you about multimodality. +3. Recognize the pattern "intractable expectation → sample it" — it recurs throughout Ch 18–20. + +## Connects To + +- **Ch 16**: the undirected models that need sampling in the first place. +- **Ch 18**: sampling as an ingredient in partition-function estimation. +- **Ch 20**: sampling as generation. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch18-partition-function.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch18-partition-function.md new file mode 100644 index 000000000..20258bad0 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch18-partition-function.md @@ -0,0 +1,69 @@ +# Chapter 18: Confronting the Partition Function + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/partition.html + +## Core Idea + +For undirected models, the log-likelihood gradient splits into a positive phase (push energy down +on data) and a negative phase (push energy up everywhere the model believes) — and the negative +phase requires samples from the model itself. Every technique in this chapter is a way to afford +that negative phase, or to avoid needing Z at all. + +## Frameworks Introduced + +- **Positive and negative phase**: the gradient of log Z is an expectation under the *model*. + This is the structural reason undirected models are expensive. +- **Contrastive divergence (CD-k)**: initialize the negative chain at the data and run k steps. + Fast; biased; produces spurious modes in regions the short chain never visits. +- **Stochastic maximum likelihood / persistent CD (PCD)**: keep the chain's state across parameter + updates so it has effectively run for a long time. Better mixing, at the cost of a chain that + can fall behind fast-moving parameters. +- **Pseudolikelihood**: replace the joint with a product of conditionals — Z cancels. Cheap; a + different objective, so it optimizes for a different thing. +- **Score matching**: match ∇_x log p instead of p, which eliminates Z because the gradient of + log Z with respect to x is zero. **Ratio matching** and **denoising score matching** are the + variants. +- **Noise-contrastive estimation (NCE)**: turn density estimation into a classification problem — + real data versus noise — and treat the normalizer as a learned parameter. +- **Annealed importance sampling (AIS)** and **bridge sampling**: estimate Z itself, mainly for + evaluation. + +## Mental Models + +- Read the whole chapter as **four escape routes from Z**: sample it (CD/PCD), sidestep it + (pseudolikelihood, score matching), learn around it (NCE), or estimate it for evaluation only + (AIS). +- Treat score matching's trick — **differentiate with respect to x, not θ, so Z vanishes** — as + the single most consequential idea in Part III for modern practice. +- Expect **spurious modes** from short-chain training; they are a predictable artifact, not a bug + in your code. + +## Anti-patterns + +- **Comparing likelihoods across undirected models without estimating Z** — the numbers are not + comparable. +- **Using CD-1 and reporting it as maximum likelihood.** + +## What changed after 2016 + +Score matching's downstream career is the story here. Denoising score matching plus a noise +schedule became score-based generative modelling (Song & Ermon 2019) and DDPM (Ho et al. 2020) — +the diffusion family that now dominates image, audio and video generation. NCE became the +backbone of contrastive representation learning (word2vec's negative sampling, then InfoNCE and +CLIP). AIS remains the standard tool for evaluating likelihoods when Z is unknown. Contrastive +divergence and PCD are largely historical. **Confidence: high.** + +**This is the chapter whose ideas travelled furthest — read it even if you never train a +Boltzmann machine.** + +## Key Takeaways + +1. When a normalizer blocks you, ask which of the four escape routes fits your objective. +2. Learn score matching properly; diffusion models are unintelligible without it. +3. Never compare unnormalized likelihoods. + +## Connects To + +- **Ch 17**: the sampling machinery the negative phase depends on. +- **Ch 14**: denoising autoencoders as score estimators. +- **Ch 20**: the generative models built from these objectives. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch19-approximate-inference.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch19-approximate-inference.md new file mode 100644 index 000000000..3b3ae0fa9 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch19-approximate-inference.md @@ -0,0 +1,63 @@ +# Chapter 19: Approximate Inference + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/inference.html + +## Core Idea + +Computing p(h|x) — the posterior over latents — is intractable for most interesting models. The +fix is to reframe inference as optimization: choose a tractable family q, and maximize a lower +bound on the log-likelihood. The gap between the bound and the truth is exactly a KL divergence. + +## Frameworks Introduced + +- **Inference as optimization**: pick q(h) to maximize the **evidence lower bound (ELBO)**, + L(q) = log p(x) − D_KL(q(h) ‖ p(h|x)). Maximizing L both fits the model and tightens the bound. +- **Expectation maximization (EM)**: alternate between setting q to the current posterior + (E-step) and maximizing with respect to parameters (M-step). +- **MAP inference and sparse coding**: taking a point estimate of h is a degenerate q (a Dirac); + sparse coding's inference step is exactly MAP inference. +- **Variational inference and mean field**: restrict q to a factorized family, q(h) = ∏ q(hᵢ), + and derive fixed-point updates. Tractability is bought with an independence assumption. +- **Reverse-KL consequences**: the ELBO uses D(q‖p), which is mode-seeking — variational + posteriors are characteristically **too narrow**, and underestimate uncertainty. +- **Learned approximate inference / amortization**: train a network to output q's parameters + directly, replacing per-example optimization with one forward pass. + +## Mental Models + +- Read the ELBO as **"log-likelihood minus the cost of your approximation"** — improving q and + improving the model are the same optimization. +- Expect **underestimated variance** from mean-field posteriors, and never report variational + uncertainty as calibrated without checking. +- Treat amortization as the **encoder** of Chapter 14: the VAE is exactly this idea plus the + reparameterization trick. + +## Anti-patterns + +- **Reporting a mean-field posterior's credible intervals as if they were exact.** +- **Blaming the model for a poor fit** that is actually a too-restrictive q — diagnose the bound + before the model. + +## What changed after 2016 + +Amortized variational inference became routine (VAEs and descendants); normalizing flows and +importance-weighted bounds (IWAE) gave tighter, more expressive posteriors than mean field. But +the strategic picture changed more: modern large generative models largely **avoid latent-variable +posteriors altogether** — autoregressive transformers have no posterior to infer, and diffusion +models use a fixed forward process, so their "inference" is trivial by construction. Variational +inference remains central in Bayesian deep learning and in structured latent-variable modelling. +**Confidence: high.** + +## Key Takeaways + +1. Write the ELBO down for any latent-variable model you train; it tells you what you are + actually optimizing. +2. Assume a mean-field posterior is over-confident until proven otherwise. +3. Ask whether your problem needs a latent posterior at all — many modern designs are structured + to avoid one. + +## Connects To + +- **Ch 13 / Ch 14**: the latent-variable models needing inference, and amortization. +- **Ch 20**: the VAE, assembled from this chapter plus the reparameterization trick. +- **Ch 3**: the KL asymmetry that dictates the narrow-posterior failure mode. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch20-deep-generative-models.md b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch20-deep-generative-models.md new file mode 100644 index 000000000..03fec7dc2 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/chapters/ch20-deep-generative-models.md @@ -0,0 +1,67 @@ +# Chapter 20: Deep Generative Models + +**Source chapter (free, official):** https://www.deeplearningbook.org/contents/generative_models.html + +## Core Idea + +The book's capstone: every generative model family circa 2016, organized by how it handles the +intractable quantity. Boltzmann machines and their deep variants pay for the partition function; +VAEs bound the likelihood; GANs avoid likelihood entirely; autoregressive models factor it away. +That taxonomy — **not** the specific models — is what still holds. + +## Frameworks Introduced + +- **Boltzmann machines, RBMs, deep belief networks, deep Boltzmann machines**: energy-based + models trained with the Ch 18 machinery. Historically pivotal; now largely superseded. +- **Variational autoencoder (VAE)**: an encoder produces q(h|x), a decoder produces p(x|h), and + the **reparameterization trick** makes the sampling step differentiable so the ELBO can be + optimized by backprop. Blurry samples are the predictable consequence of the likelihood + objective plus a limited posterior family. +- **Generative adversarial networks (GANs)**: a generator and a discriminator in a minimax game; + no explicit likelihood, sharp samples, unstable training, and mode collapse as the + characteristic failure. +- **Autoregressive / fully-visible belief networks**: factor p(x) by the chain rule and model each + conditional. Exact likelihood, sequential sampling. (NADE, PixelRNN/PixelCNN, WaveNet era.) +- **Generative stochastic networks; denoising-based generation**: learning a transition operator + rather than a distribution — the direct ancestor of diffusion. +- **Evaluating generative models**: the chapter's warning that likelihood, sample quality and + downstream usefulness are **three different axes** that routinely disagree. + +## Mental Models + +- Classify any new generative model by **what it does about the intractable term**: bound it, + avoid it, factor it away, or learn a sampler directly. New families are new answers to that one + question. +- Read blurriness vs mode collapse as **the KL asymmetry from Ch 3 made visible**: likelihood-based + models cover modes and blur; adversarial models sharpen and drop modes. +- Never accept a **single** evaluation number for a generative model. + +## Anti-patterns + +- **Comparing FID across papers with different preprocessing** — the metric is not portable. +- **Treating sample quality as evidence of density estimation quality**, or vice versa. +- **Starting a new project with a Boltzmann machine.** + +## What changed after 2016 + +The most-superseded chapter in the book, and worth reading anyway for its taxonomy. +**Diffusion models** (Ho et al. 2020, built on Ch 18's denoising score matching and Ch 14's +denoising autoencoders) displaced GANs for image, audio and video generation. **Autoregressive +transformers** became the dominant generative model overall — the chain-rule factorization +described here, scaled. VAEs persist mainly as latent-space compressors inside latent diffusion +pipelines rather than as end-user generators. GANs remain useful for fast, low-step generation. +Boltzmann machines are history. Evaluation remains unsolved, exactly as the chapter warned. +**Confidence: high.** + +## Key Takeaways + +1. Use the taxonomy, not the model list — it classifies architectures invented since. +2. Expect blurring from likelihood objectives and mode-dropping from adversarial ones; pick your + failure mode deliberately. +3. Evaluate generative models on at least likelihood, sample quality, and downstream use. + +## Connects To + +- **Ch 18**: score matching, whose descendants replaced most of this chapter. +- **Ch 19**: the ELBO the VAE optimizes. +- **references/book_to_2026_delta.md**: the diffusion and autoregressive-transformer lines. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/cheatsheet.md b/engineering/deep-learning-book/skills/deep-learning-book/cheatsheet.md new file mode 100644 index 000000000..2d529418a --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/cheatsheet.md @@ -0,0 +1,73 @@ +# Cheatsheet + +Decision rules and thresholds. One line each; the chapter has the reasoning. + +## Loss selection (ch03, ch06) + +| Target | Distribution | Output unit | Loss | +|---|---|---|---| +| Real, unbounded | Gaussian | linear | MSE | +| Binary | Bernoulli | sigmoid | binary cross-entropy | +| One of K | Categorical | softmax | cross-entropy | +| Real, heavy-tailed | Laplace | linear | MAE | +| Multimodal real | Mixture | mixture density | NLL of the mixture | + +Never pair sigmoid output with MSE — the gradient vanishes where the model is most wrong. + +## Underfit vs overfit (ch05, ch11) + +| Train error | Val error | Verdict | First action | +|---|---|---|---| +| High | High | Underfit / optimization | Check LR, init, capacity — **not** more data | +| Low | High | Overfit | More data → augmentation → regularization | +| Low | Low | Working | Stop; check the metric is the right one | +| ~0 on 20 examples: fails | — | Bug | Fix the pipeline before anything else | + +Past the interpolation threshold, prefer more data / longer training / more regularization over +shrinking the model (double descent, ch05). + +## Optimizer defaults (ch08) + +| Situation | Reach for | +|---|---| +| Any starting point | AdamW, LR ~1e-3 (small nets) / ~1e-4 (large), warmup + cosine decay | +| Recurrence in the graph | Add gradient clipping, always | +| Tiny batches | Group/layer norm, not batch norm | +| Loss flat, grad norm large | Ill-conditioning — momentum, adaptive, or normalization | +| Loss NaN | Numerics first: log(0), div by ~0, exp overflow, exploding grad | + +## Regularization ladder (ch07) + +More real data → label-preserving augmentation → early stopping → decoupled weight decay → +label smoothing → dropout → smaller model. One at a time; measure the gap after each. + +## Architecture prior (ch09, ch10) + +| Data | Prior that fits | Note | +|---|---|---| +| Grid, translation-invariant statistics | Convolution | Cheapest when data is limited | +| Sequence, long-range dependence | Attention/transformer | The 2016 RNN advice is superseded | +| Sequence, very long context | State-space / linear recurrence | Post-2016; ch10's gradient analysis applies | +| Arbitrary tabular columns | Neither | Locality prior is false | + +## Generative model taxonomy (ch20) + +| Family | Handles Z by | Characteristic failure | +|---|---|---| +| Autoregressive | Chain rule — no Z | Slow sequential sampling | +| VAE | Bounding likelihood (ELBO) | Blurry samples | +| GAN | Avoiding likelihood | Mode collapse, unstable training | +| Diffusion / score-based | Learning ∇ log p | Many sampling steps (mitigable) | +| Boltzmann machines | Sampling the negative phase | Historical; do not start here | + +## Numerical hygiene (ch04) + +Log-space for probability products · stabilized/fused softmax and cross-entropy · clip gradients +with recurrence · bf16 over fp16 when range matters · check condition number before blaming LR. + +## Study-order rule (ch01–ch20) + +Ch 5 and Ch 11 are the highest-value chapters for a practitioner and can be read early. +Ch 2–4 are prerequisites for Ch 8 specifically. Ch 13→14→19→20 is the only strict chain in +Part III. Ch 18 is worth reading even if you never train an undirected model — score matching +became diffusion. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/glossary.md b/engineering/deep-learning-book/skills/deep-learning-book/glossary.md new file mode 100644 index 000000000..c1bd4cc71 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/glossary.md @@ -0,0 +1,58 @@ +# Glossary + +Terms as this skill uses them, with the chapter that develops each. Definitions are written for +lookup, not for teaching — read the chapter file for the reasoning. + +- **Adam / AdamW** (ch08, ch07) — adaptive optimizer combining momentum with per-parameter scaling. AdamW decouples weight decay from the gradient, which Adam+L2 does not do correctly. +- **Amortized inference** (ch19, ch14) — training a network to predict a posterior's parameters instead of optimizing per example. +- **Ancestral sampling** (ch16) — sampling a directed model by drawing each variable after its parents. +- **Attention** (ch10) — a learned weighted read over a set of positions; introduced here as a fix for the seq2seq bottleneck, later the whole architecture. +- **Backpropagation** (ch06) — reverse-mode automatic differentiation over the computation graph. +- **Batch normalization** (ch08) — reparameterization normalizing layer inputs by batch statistics. +- **Bias–variance decomposition** (ch05) — expected error as bias² + variance + irreducible noise. +- **Capacity, effective vs representational** (ch05) — functions reachable by the optimizer vs functions expressible by the architecture. +- **Condition number** (ch02, ch04) — ratio of largest to smallest singular value; how amplified small perturbations become. +- **Contrastive divergence (CD-k)** (ch18) — biased approximation of the negative phase using a short chain started at the data. +- **Cross-entropy** (ch03) — H(p,q) = H(p) + D_KL(p‖q); minimizing it over q minimizes KL. +- **Curse of dimensionality** (ch05, ch01) — configurations grow exponentially with dimension, defeating local-interpolation methods. +- **Denoising autoencoder (DAE)** (ch14) — reconstructs clean input from a corrupted copy; its learned map estimates the score. +- **Disentangled representation** (ch15) — separate generative factors on separate directions. +- **Distributed representation** (ch15) — n features describing exponentially many regions, versus one-hot's n. +- **d-separation** (ch16) — reading conditional independence off a directed graph. +- **Double descent** (ch05) — test error falling again past the interpolation threshold; post-dates the book and qualifies its U-curve. +- **ELBO** (ch19) — evidence lower bound; log p(x) minus KL(q ‖ posterior). +- **Empirical risk minimization** (ch08) — minimizing training loss as a surrogate for unmeasurable true risk. +- **Energy-based model** (ch16) — p(x) ∝ exp(−E(x)). +- **Equivariance** (ch09) — a shift in input produces the corresponding shift in output; convolution's defining property. +- **Exposure bias** (ch10) — train/inference mismatch from teacher forcing. +- **Gradient clipping** (ch08, ch10) — rescaling gradients above a norm threshold to survive cliffs. +- **Ill-conditioning** (ch04, ch08) — wide Hessian eigenvalue spread; loss stalls while gradient norm stays large. +- **Importance sampling** (ch17) — sampling from q and reweighting by p/q. +- **KL divergence** (ch03) — asymmetric divergence between distributions; direction decides mode-covering vs mode-seeking. +- **Manifold hypothesis** (ch05, ch14) — real data concentrates near a low-dimensional manifold. +- **Maximum likelihood estimation (MLE)** (ch05) — parameters maximizing data probability; the justification for standard losses. +- **Mean field** (ch19) — factorized variational family; buys tractability with an independence assumption. +- **Mixing time / burn-in** (ch17) — steps an MCMC chain needs before its samples are usable. +- **Mode collapse** (ch20) — a generator covering only part of the data distribution. +- **Momentum / Nesterov momentum** (ch08) — velocity accumulation across gradient steps. +- **No Free Lunch theorem** (ch05) — averaged over all distributions, all learners tie; priors carry all leverage. +- **Noise-contrastive estimation (NCE)** (ch18) — density estimation recast as data-versus-noise classification. +- **Partition function (Z)** (ch16, ch18) — the normalizer of an undirected model; the central intractability of Part III. +- **Persistent contrastive divergence (PCD)** (ch18) — CD with a chain persisted across updates. +- **Pooling** (ch09) — local aggregation giving approximate invariance to small translations. +- **Positive / negative phase** (ch18) — the two terms of an undirected model's likelihood gradient. +- **Pseudolikelihood** (ch18) — product of conditionals, in which Z cancels. +- **Receptive field** (ch09) — the input region influencing one output unit. +- **Regularization** (ch07, ch05) — any change intended to reduce generalization error but not training error. +- **Reparameterization trick** (ch20) — expressing a sample as a differentiable function of noise, making the ELBO backprop-able. +- **Representation learning** (ch15, ch01) — learning features rather than hand-designing them. +- **Saddle point** (ch08) — critical point that is a minimum in some directions and a maximum in others; the dominant critical point in high dimensions. +- **Score matching** (ch18) — matching ∇_x log p instead of p, so Z vanishes; the basis of diffusion models. +- **SGD, minibatch** (ch08) — gradient descent on noisy gradients from data subsets. +- **Singular value decomposition (SVD)** (ch02) — A = U D Vᵀ for any matrix. +- **Sparse coding** (ch13) — heavy-tailed latent prior with inference by optimization. +- **Teacher forcing** (ch10) — training an autoregressive model on ground-truth history. +- **Universal approximation theorem** (ch06) — one wide hidden layer suffices in principle; silent on width, learnability and sample cost. +- **Vanishing / exploding gradients** (ch10, ch08) — repeated Jacobian multiplication shrinking or growing gradients through depth or time. +- **Variational autoencoder (VAE)** (ch20, ch19) — amortized variational inference plus the reparameterization trick. +- **Weight decay** (ch07) — pulling parameters toward zero; equivalent to L2 for SGD, not for Adam. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/patterns.md b/engineering/deep-learning-book/skills/deep-learning-book/patterns.md new file mode 100644 index 000000000..9161d4fc8 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/patterns.md @@ -0,0 +1,92 @@ +# Patterns + +Recurring techniques from the book, stated as instruments with their trade-offs. Each names the +chapter that develops it. + +## Derive the loss from the output distribution (ch03, ch06) + +Choose what p(y|x) is, then take its negative log. Gaussian → MSE, Bernoulli → binary +cross-entropy, categorical → cross-entropy, Laplace → MAE, mixture → mixture density loss. +**Trade-off:** none — this is strictly better than picking a loss by habit. **Failure mode it +prevents:** MSE on bounded, count-valued or heavily skewed targets. + +## Work in log-space (ch04) + +Sums of logs instead of products of probabilities; stabilized softmax and `log_softmax` rather +than hand-rolled exp/normalize. **Trade-off:** slightly more care at implementation time. +**Prevents:** silent underflow, NaN losses, and inf logits. + +## Train-error-first triage (ch11, ch05) + +Read training error before deciding anything. High training error → capacity or optimization is +the bottleneck; more data will not help. Low training error with high validation error → data or +regularization. **Trade-off:** requires honest, non-leaky splits. **Prevents:** the most expensive +common mistake, which is collecting data to fix underfitting. + +## Overfit a tiny subset as a smoke test (ch11) + +Take 10–50 examples and drive training loss to ~0. If you cannot, you have a bug — not a hard +problem. **Trade-off:** minutes. **Prevents:** weeks of tuning around a broken data pipeline, +a wrong loss reduction, or a detached gradient. + +## Random search over grid search (ch11) + +With more than about two hyperparameters, random search finds better configurations for the same +budget because it does not spend trials re-testing unimportant dimensions. **Trade-off:** results +are less tidy to tabulate. **Prevents:** exponential waste in dimensions that do not matter. + +## Regularize in cost order (ch07) + +More real data → augmentation with label-preserving transforms → early stopping → weight decay → +dropout → architecture change. Add one at a time and measure the train/val gap after each. +**Trade-off:** slower than stacking everything. **Prevents:** an unattributable result you cannot +tune. + +## Parameter sharing over parameter penalties (ch07, ch09) + +When a real invariance exists, encode it structurally (convolution, weight tying) rather than +penalizing a free parameter into behaving. **Trade-off:** the prior is hard — wrong invariance +means a wrong model, with no way for data to override it. **Prevents:** paying for capacity you +then have to regularize away. + +## Diagnose by gradient behaviour (ch08, ch04) + +Gradient norm exploding → clip. Norm large, loss flat → ill-conditioning; use momentum, an +adaptive optimizer, or normalization. Norm near zero with high loss → saturation or dead units; +check initialization and activations. Loss NaN → numerics before modelling. **Trade-off:** +requires instrumentation. **Prevents:** changing the architecture to fix an optimizer problem. + +## Tune learning rate and schedule before optimizer family (ch08) + +The schedule usually dominates the choice among SGD/Adam variants. **Trade-off:** none. **Prevents:** +optimizer-shopping while the real problem is a step size two orders of magnitude off. + +## Name what prevents the identity map (ch14) + +Every autoencoder needs an answer: bottleneck, sparsity penalty, input corruption, or Jacobian +contraction. **Trade-off:** each constraint defines a different notion of useful. +**Prevents:** an overcomplete autoencoder that learns a copy and reports a low loss. + +## Escape the partition function deliberately (ch18, ch16) + +Four routes: sample the negative phase (CD/PCD), sidestep Z algebraically (pseudolikelihood, +score matching), learn around it (NCE), or estimate it for evaluation only (AIS). **Trade-off:** +each optimizes a different objective, so they are not interchangeable. **Prevents:** discovering +mid-project that your undirected model cannot be trained or compared. + +## Pick your generative failure mode (ch20, ch03) + +Likelihood-based objectives cover modes and blur. Adversarial objectives sharpen and drop modes. +This follows from KL direction, so it is a design choice, not bad luck. **Trade-off:** you must +decide which error your application tolerates. **Prevents:** treating blurriness as a bug to be +tuned away. + +## Evaluate representations by transfer, not reconstruction (ch15, ch14) + +Linear probes and downstream task performance. **Trade-off:** needs a downstream task. +**Prevents:** optimizing reconstruction error into a code that memorized the input. + +## Report Monte Carlo estimates with error bars (ch17) + +Standard error and a mixing diagnostic, always. **Trade-off:** more reporting. +**Prevents:** a confidently unimodal answer from a chain that never left its starting mode. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/references/book_to_2026_delta.md b/engineering/deep-learning-book/skills/deep-learning-book/references/book_to_2026_delta.md new file mode 100644 index 000000000..452b89dd9 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/references/book_to_2026_delta.md @@ -0,0 +1,101 @@ +# The 2016 → 2026 Delta + +The book was published in 2016. *Attention Is All You Need* appeared in 2017. Everything below +tracks what a reader should hold, discount, or replace — per chapter, with a confidence level on +each claim. + +Confidence levels: 🟢 well-established and replicated · 🟡 supported but contested or +context-dependent · 🔴 folklore, named as folklore. + +--- + +## Chapters that aged well (read as current) + +| Ch | Why it holds | +|---|---| +| 2, 3, 4 | Mathematics does not age. 🟢 | +| 5 | The learning-theory frame is current — with one correction (double descent, below). 🟢 | +| 11 | The practical workflow became the backbone of MLOps practice. 🟢 | +| 16 | The directed/undirected taxonomy classifies models invented since, including transformers as chain-rule factorizations. 🟢 | +| 18 | Score matching became diffusion; NCE became contrastive learning. The chapter's ideas travelled further than its models. 🟢 | + +## The five corrections that matter + +### 1. Double descent qualifies the U-curve (ch05) + +The classical capacity curve — error falls, then rises past the optimum — is incomplete. Past the +interpolation threshold, test error can descend a second time. Belkin et al. (2019) named it; +Nakkiran et al. (2020) showed model-wise, epoch-wise and sample-wise variants in deep networks. +The bias–variance algebra is unchanged; the *advice* "shrink the model when it overfits" is no +longer the only correct move. 🟢 + +### 2. AdamW: weight decay ≠ L2 under adaptive optimizers (ch07, ch08) + +Adding an L2 term to the loss and applying weight decay to the update are equivalent for plain +SGD and **not** equivalent for Adam, because the adaptive denominator rescales the penalty. +Loshchilov & Hutter (2017/2019) decoupled them; AdamW is now the default. The book's Ch 7 treats +the two as interchangeable. 🟢 + +### 3. Transformers displaced recurrence (ch10, ch12) + +Vaswani et al. (2017) removed recurrence entirely: attention gives an O(1) path between any two +positions and parallelizes over sequence length. What survives from Ch 10: the vanishing/exploding +gradient analysis, gradient clipping, teacher forcing, exposure bias. What is superseded: the +architecture recommendation. Note the return of linear-time recurrence in state-space models +(S4, 2021; Mamba, 2023) for long context — which makes Ch 10's analysis live again. 🟢 + +### 4. Diffusion displaced the Part III generative models (ch14, ch18, ch20) + +The line runs directly through the book: denoising autoencoders (Ch 14) → denoising score +matching (Ch 18) → score-based generative models (Song & Ermon, 2019) → DDPM (Ho et al., 2020). +Diffusion now dominates image, audio and video generation; autoregressive transformers dominate +text. VAEs survive mainly as latent-space compressors inside latent-diffusion pipelines. GANs +remain useful for few-step generation. Boltzmann machines are historical. 🟢 + +### 5. Self-supervised learning vindicated Ch 15 while replacing its methods + +The chapter's bet — that unsupervised representation learning would matter — was right, and every +specific method it lists was replaced. Contrastive (SimCLR, MoCo, CLIP), masked prediction (BERT, +MAE) and next-token prediction at scale are the modern routes. Two corrections: greedy layer-wise +pretraining is now purely historical 🟢, and unsupervised disentanglement was shown impossible +without inductive biases or supervision (Locatello et al., 2019) 🟢. + +## Additions the book has no chapter for + +| Topic | Status | +|---|---| +| **Neural scaling laws** (Kaplan 2020; Hoffmann 2022) | Quantifies Ch 1's "scale matters" and Ch 11's data-vs-capacity decision. 🟢 | +| **Normalization placement** — pre-norm vs post-norm residual, LayerNorm/RMSNorm over BatchNorm | Standard for deep sequence stacks. 🟢 | +| **Warmup + cosine decay schedules** | Standard for transformer training. 🟢 | +| **Calibration** (Guo et al., 2017) | Modern networks are systematically overconfident; temperature scaling is the cheap fix. Ch 3 does not mention it. 🟢 | +| **RLHF / DPO alignment** | Entirely outside the book. 🟢 | +| **Mixture-of-Experts** | The mature form of Ch 12's conditional computation. 🟢 | +| **Mixed precision (fp16 loss scaling, bf16)** | Made Ch 4's numerics an operational daily concern. 🟢 | +| **Lottery-ticket / pruning theory** (Frankle & Carbin, 2019) | Active; the strong form remains contested. 🟡 | +| **"Batch norm works by reducing internal covariate shift"** | The original explanation; challenged by Santurkar et al. (2018), who attribute the effect to smoothing the loss landscape. Treat the mechanism as unsettled. 🟡 | +| **"Local minima are the problem in deep nets"** | 🔴 Folklore the book itself corrects: high-dimensional critical points are overwhelmingly saddles (Ch 8). | +| **"You need a GPU cluster to learn deep learning"** | 🔴 Folklore. Every mechanism in Parts I–II is observable on a laptop-scale model. | + +## How to use this file + +When a chapter's advice conflicts with current practice, the conflict is almost always in the +**recommendation**, not the **analysis**. The book explains why things fail; the field has changed +what it reaches for. Keep the diagnosis, replace the prescription. + +## Sources + +1. Vaswani et al., "Attention Is All You Need," NeurIPS 2017 — arXiv:1706.03762. +2. Loshchilov & Hutter, "Decoupled Weight Decay Regularization," ICLR 2019 — arXiv:1711.05101. +3. Belkin, Hsu, Ma & Mandal, "Reconciling modern machine-learning practice and the classical + bias–variance trade-off," PNAS 116(32), 2019; Nakkiran et al., "Deep Double Descent," ICLR 2020. +4. Ho, Jain & Abbeel, "Denoising Diffusion Probabilistic Models," NeurIPS 2020 — arXiv:2006.11239; + Song & Ermon, "Generative Modeling by Estimating Gradients of the Data Distribution," + NeurIPS 2019. +5. Kaplan et al., "Scaling Laws for Neural Language Models," 2020 — arXiv:2001.08361; + Hoffmann et al., "Training Compute-Optimal Large Language Models," 2022 — arXiv:2203.15556. +6. Locatello et al., "Challenging Common Assumptions in the Unsupervised Learning of Disentangled + Representations," ICML 2019 (best paper) — arXiv:1811.12359. +7. Guo, Pleiss, Sun & Weinberger, "On Calibration of Modern Neural Networks," ICML 2017; + Santurkar et al., "How Does Batch Normalization Help Optimization?," NeurIPS 2018. +8. Dosovitskiy et al., "An Image is Worth 16x16 Words" (ViT), ICLR 2021 — arXiv:2010.11929; + Liu et al., "A ConvNet for the 2020s" (ConvNeXt), CVPR 2022. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/references/prerequisite_map.md b/engineering/deep-learning-book/skills/deep-learning-book/references/prerequisite_map.md new file mode 100644 index 000000000..e34d88c4e --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/references/prerequisite_map.md @@ -0,0 +1,93 @@ +# Prerequisite Map + +The book's part structure is not its dependency structure. Readers who go strictly front-to-back +spend weeks in Chapters 2–4 before touching a network, and a large fraction stop there. This file +gives the actual graph. + +--- + +## The real dependencies + +``` +ch01 ──────────────────────────────────► (context only; no hard dependents) + +ch02 (linear algebra) ──┬──► ch04 ──┬──► ch08 ──► ch09, ch10 + │ │ +ch03 (probability) ─────┴──► ch05 ──┴──► ch06 ──► ch07 ──► ch11 + │ + └──► ch13 ──► ch14 ──► ch15 + │ +ch03 ──► ch16 ──► ch17 ──► ch18 ──► ch19 ──► ch20 +``` + +**Hard prerequisites** (skipping these makes the target chapter unreadable, not merely harder): + +| To read | You need | Specifically | +|---|---|---| +| ch08 | ch02, ch04 | eigenvalues, condition number, Hessian, Taylor expansion | +| ch06 | ch03 | the output distributions that determine output units and losses | +| ch07 | ch05, ch04 | bias–variance; KKT for the constraint view of penalties | +| ch18 | ch16, ch17 | the partition function; sampling | +| ch19 | ch03 | KL divergence, and its asymmetry | +| ch20 | ch13, ch19 | the latent-variable template; the ELBO | + +**Soft prerequisites** — helpful, not blocking: ch02 before ch13 (PCA), ch09 before ch12, +ch10 before ch12. + +## Chapters you can read early, out of order + +- **ch05** — the single highest-value chapter for a practitioner. Needs only basic probability. +- **ch11** — readable on day one and immediately actionable. Depends on ch05's vocabulary only. +- **ch01** — context; skim it. +- **ch09** — comprehensible with ch06 alone if you accept the optimization details on faith. + +## Chapters you can defer or skip, by goal + +| Goal | Skip or defer | +|---|---| +| Applied practitioner shipping models | Part III entirely (ch13–20), except ch14's denoising section | +| Preparing for modern generative modelling | Nothing in Part III — but read ch18 before ch20 | +| Interview / fundamentals refresher | ch12 (dated), ch17–19 (unless the role is probabilistic ML) | +| Understanding transformers | ch10 for the gradient analysis; then leave the book for the 2017 paper | +| Research in probabilistic ML | Everything; Part III is the reason this book has no substitute | + +## The Part I wall, and how to get through it + +Chapters 2–4 are compressed reference material, not pedagogy. Three viable strategies: + +1. **Read-on-demand** (recommended for applied readers): skim ch2–4 once for vocabulary, start at + ch05, and return to a specific section when ch08 uses it. The prerequisite table above tells + you exactly which section. +2. **Front-load** (recommended if you intend to read Part III): work ch2–4 properly with a + separate linear algebra source alongside. Budget 2–3× the page count in time. +3. **Substitute**: use a dedicated linear algebra or probability text for Part I and treat these + chapters purely as a notation reference for the rest of the book. + +Strategy 1 is right for most readers and is what `scripts/reading_path_planner.py` recommends by +default. Strategy 2 is right when Part III is the destination — its chapters compound, and gaps +compound with them. + +## Time budgeting + +Reported reading times vary by an order of magnitude, so treat any figure as a planning +assumption, not a fact. As a planning heuristic used by the reading-path planner: a Part I or +Part III chapter is roughly 1.5–2× the time of a Part II chapter of the same length, because the +derivation density is higher. The planner exposes its per-chapter assumptions in +`--output json` so you can recalibrate them against your own first chapter. + +## Sources + +1. Goodfellow, Bengio & Courville, *Deep Learning*, MIT Press 2016 — table of contents and part + structure: https://www.deeplearningbook.org/ +2. Sweller, van Merriënboer & Paas, "Cognitive Architecture and Instructional Design," *Educational + Psychology Review* 10(3), 1998 — element interactivity and intrinsic load, which is what makes + ch2–4 expensive. +3. Sweller & Cooper, "The Use of Worked Examples as a Substitute for Problem Solving," *Cognition + and Instruction* 2(1), 1985 — the worked-example effect for novices. +4. Bjork & Bjork, "Making Things Hard on Yourself, But in a Good Way: Creating Desirable + Difficulties to Enhance Learning," 2011. +5. Roediger & Karpicke, "Test-Enhanced Learning," *Psychological Science* 17(3), 2006. +6. Ericsson, Krampe & Tesch-Römer, "The Role of Deliberate Practice in the Acquisition of Expert + Performance," *Psychological Review* 100(3), 1993. +7. Chi, Bassok, Lewis, Reimann & Glaser, "Self-Explanations: How Students Study and Use Examples + in Learning to Solve Problems," *Cognitive Science* 13(2), 1989. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/references/rights_and_use.md b/engineering/deep-learning-book/skills/deep-learning-book/references/rights_and_use.md new file mode 100644 index 000000000..276644e3a --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/references/rights_and_use.md @@ -0,0 +1,80 @@ +# Why This Is a Companion, Not a Compiled Copy + +This repository ships `engineering/book-to-skill`, which compiles a document on disk into a +knowledge-base skill. This skill was **not** produced that way, and the reason is worth stating +plainly, because the same reasoning applies to every future request of the form "turn this book +into a skill." + +--- + +## The three facts that decided it + +1. **The book is copyrighted.** *Deep Learning* (Goodfellow, Bengio & Courville) is published by + MIT Press, 2016. Free-to-read is not free-to-redistribute; the two are unrelated. +2. **The publisher's position is explicit.** deeplearningbook.org states that the HTML-only + presentation exists as a deliberate friction against copying and editing, required by the + authors' contract with MIT Press, and that PDF distribution is not permitted. +3. **This repository is public.** `book-to-skill`'s own rights gate refuses a `shareable` + distribution without one of `public-domain`, `open-license`, `internal-docs`, or + `author-permission`. None applies here. Its `references/rights_and_provenance.md` lists + "publish a compiled skill of a copyrighted book to a public marketplace" under **Do not**. + +`book-to-skill` also has a hard rule that it converts files already on disk and never scrapes a +book from the web — so the requested pipeline could not have been run against a URL regardless. + +## What was built instead + +An **original companion**: every chapter file is written from domain knowledge, states what the +chapter establishes, how to use it, and where it has aged, and links to the official free +chapter. The organizing structure — 20 chapters in 3 parts — is the book's published table of +contents, which is factual metadata, not expression. + +Concretely, this skill contains: + +- **No passages, paragraphs, sentences, or figures from the book.** +- **No paraphrase-per-paragraph** — the compression ratio is roughly a whole chapter to a page, + which is a synthesis, not a substitute. +- **Links to the official chapters**, so the skill sends readers *to* the book rather than + replacing it. +- **Original material the book does not contain**: the 2016→2026 delta layer, the prerequisite + graph, the study method, and four executable tools. + +Under the idea/expression line (17 U.S.C. §102(b); *Baker v. Selden*), what this skill carries — +the names of methods, the structure of an argument, decision rules stated plainly, and terms +defined in other words — sits on the ideas side. The chapters themselves, in the authors' prose, +sit on the expression side, and stay at deeplearningbook.org. + +**Not legal advice.** This is the posture and its reasoning. Where money or publication is +involved, ask a lawyer. + +## The rule this establishes for the repository + +> When a user asks to convert a copyrighted work into a shareable skill, build a **companion** +> that indexes and updates the source, not a **compilation** that reproduces it. Compile only +> when the rights gate clears — and keep compiled output local when it does not. + +A companion is often the better artifact anyway. A compilation freezes a source at its +publication date; a companion can say which parts of a ten-year-old text are still true, which +is precisely what a reader of a 2016 deep learning book needs most. + +## Use this skill correctly + +- **Do** read the book at deeplearningbook.org; this navigates it. +- **Do** use the delta reference before acting on any 2016-era recommendation. +- **Don't** treat the chapter files as a substitute for reading the chapters — they are the + answer key for retrieval practice, not the material. +- **Don't** extend this skill by pasting book text into it. That converts a companion into the + thing this file exists to avoid. + +## Sources + +1. Goodfellow, Bengio & Courville, *Deep Learning*, MIT Press, 2016 — https://www.deeplearningbook.org/ +2. deeplearningbook.org — the site's own statement on its HTML-only format and PDF distribution. +3. 17 U.S.C. §102(b) — ideas, procedures, processes and methods of operation are outside + copyright. +4. *Baker v. Selden*, 101 U.S. 99 (1879) — the idea/expression dichotomy. +5. 17 U.S.C. §107 — fair use as a four-factor defence, assessed case by case. +6. *Authors Guild v. Google, Inc.*, 804 F.3d 202 (2d Cir. 2015) — indexing that does not + substitute for the original held transformative. +7. This repository: `engineering/book-to-skill/skills/book-to-skill/references/rights_and_provenance.md` + (the rights gate) and its SKILL.md hard rules 1 and 6. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/references/study_method_canon.md b/engineering/deep-learning-book/skills/deep-learning-book/references/study_method_canon.md new file mode 100644 index 000000000..4055de063 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/references/study_method_canon.md @@ -0,0 +1,99 @@ +# Studying a Hard Technical Text + +This book defeats more readers than it teaches, and the reason is rarely ability. It is a +reference text with textbook page counts, high element interactivity, and no exercises. The +methods below are the evidence-based ones that apply specifically to that shape of material. + +--- + +## The five that matter here + +### 1. Retrieval practice beats re-reading + +Testing yourself on material produces markedly better long-term retention than reviewing it, +even when re-reading feels more productive at the time (Roediger & Karpicke, 2006). For this +book, the cheap version: after each chapter, close it and write the chapter's core idea, three +concepts, and one thing you could not explain. The chapter files in this skill are structured to +be *checked against*, not read first — use them as the answer key. + +### 2. Spacing beats massing + +Distributed practice outperforms cramming for durable retention (Cepeda et al., 2006). Practical +rule: revisit ch05 and ch11 at one week and one month. They are the chapters whose vocabulary +every later chapter reuses, so spaced review of those two pays across the whole book. + +### 3. Worked examples before problem-solving, for novices + +For learners without domain schemas, studying worked examples is more effective than solving +equivalent problems — the expertise-reversal effect means this flips once you *have* the schemas +(Sweller & Cooper, 1985; Kalyuga et al., 2003). Consequence for this book: in Ch 8 and Ch 18, work +the derivations the text presents before attempting your own; in Ch 11, do the opposite — you +already have the schema, so run the workflow on a real project. + +### 4. Self-explanation + +Students who explain each step to themselves while studying examples learn substantially more than +those who do not (Chi et al., 1989). The highest-yield prompt for this book: after every +equation, answer "what would change if this term were removed?" It converts symbol-tracking into +understanding, and it is exactly how the book's own arguments are structured. + +### 5. Desirable difficulties + +Conditions that slow acquisition often improve retention and transfer (Bjork & Bjork, 2011). +Interleaving Part II chapters with attempts to apply them beats reading Part II straight through, +even though it feels slower. This is the empirical case for the reading-path planner's +"read one chapter, then implement something" cadence. + +--- + +## Managing cognitive load in Chapters 2–4 and 16–20 + +High **element interactivity** — where symbols cannot be understood one at a time because they +only make sense in relation to each other — is what makes these chapters expensive (Sweller, van +Merriënboer & Paas, 1998). Intrinsic load cannot be reduced by better presentation; it can only be +sequenced. Two consequences: + +- **Do not skim a high-interactivity derivation.** Skimming works for prose and fails for + Chapter 18. Either work it or defer it explicitly. +- **Chunk by named object.** "Score matching" as one unit is cheaper than seven equations. Every + chapter file in this skill names its objects for exactly this reason. + +## A cadence that works for this book + +1. **Skim** the chapter's section headings and the summary. Five minutes. +2. **Read** with a pen, working every derivation you intend to keep. +3. **Close the book** and write, from memory: the core idea, three concepts, one confusion. +4. **Check** against this skill's chapter file — it is deliberately written as a synthesis, so a + mismatch is informative. +5. **Apply** something small before moving on (a five-line implementation, a diagnosis of a real + training run, a re-derivation). +6. **Revisit** at one week for ch05, ch08, ch11, ch18. + +Step 5 is the one most readers skip and the one that decides whether any of it survives. + +## What not to do + +- **Reading front-to-back through Part I as a first pass.** See `prerequisite_map.md`. +- **Highlighting.** It produces a strong feeling of learning and among the weakest measured + effects of the common study techniques (Dunlosky et al., 2013). +- **Taking notes that restate the text.** Restatement is transcription. Write the answer to + "when would I use this?" instead — that is the note that gets read again. +- **Waiting to "finish the math" before touching a model.** The book's own Ch 11 argues against + it: build the end-to-end pipeline early. + +## Sources + +1. Roediger & Karpicke, "Test-Enhanced Learning: Taking Memory Tests Improves Long-Term + Retention," *Psychological Science* 17(3), 2006. +2. Cepeda, Pashler, Vul, Wixted & Rohrer, "Distributed Practice in Verbal Recall Tasks: A Review + and Quantitative Synthesis," *Psychological Bulletin* 132(3), 2006. +3. Sweller & Cooper, "The Use of Worked Examples as a Substitute for Problem Solving in Learning + Algebra," *Cognition and Instruction* 2(1), 1985; Kalyuga, Ayres, Chandler & Sweller, "The + Expertise Reversal Effect," *Educational Psychologist* 38(1), 2003. +4. Chi, Bassok, Lewis, Reimann & Glaser, "Self-Explanations," *Cognitive Science* 13(2), 1989. +5. Bjork & Bjork, "Making Things Hard on Yourself, But in a Good Way," in *Psychology and the Real + World*, 2011. +6. Sweller, van Merriënboer & Paas, "Cognitive Architecture and Instructional Design," + *Educational Psychology Review* 10(3), 1998. +7. Dunlosky, Rawson, Marsh, Nathan & Willingham, "Improving Students' Learning With Effective + Learning Techniques," *Psychological Science in the Public Interest* 14(1), 2013. diff --git a/engineering/deep-learning-book/skills/deep-learning-book/scripts/capacity_planner.py b/engineering/deep-learning-book/skills/deep-learning-book/scripts/capacity_planner.py new file mode 100644 index 000000000..6bc7a6491 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/scripts/capacity_planner.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""capacity_planner.py — capacity, the generalization gap, and what to spend next. + +Chapter 5 gives the bias-variance budget and the classical U-shaped capacity curve. +Chapter 7 gives the regularizers you spend from that budget. This tool combines them +into one recommendation: given a measured gap and a parameters-per-example ratio, what +is the cheapest next purchase? + +It carries one correction the 2016 text predates. The classical advice "shrink the +model when it overfits" assumed the underparameterized side of the U-curve. Past the +interpolation threshold, test error can fall again (double descent; Belkin et al. 2019, +Nakkiran et al. 2020), so in the overparameterized regime this tool ranks *more data, +more regularization, longer training* above *smaller model* and says why. + +Standard library only. No network calls. + +Exit codes: + 0 a plan was produced and the fit looks balanced + 1 a plan was produced and an action is recommended + 2 usage error (argparse) + 4 inputs are inconsistent (e.g. validation error below training error by a wide + margin, which usually means a leaky split rather than a good model) +""" + +from __future__ import annotations + +import argparse +import json +import sys + +# Regularization ladder in cost order (ch07). Cost is effort+risk, not compute. +LADDER = [ + ("more-real-data", "Collect more real labelled data", + "The only intervention that reduces variance without adding bias. Everything below " + "is a substitute for it.", "ch05, ch07"), + ("augmentation", "Label-preserving data augmentation", + "Cheapest substitute for real data where a real invariance exists. Verify the " + "transform preserves the label — a flip destroys the label on digits.", "ch07"), + ("early-stopping", "Early stopping on a validation metric", + "Approximately equivalent to L2 under a quadratic approximation, at near-zero cost. " + "Usually the first thing to try.", "ch07"), + ("weight-decay", "Decoupled weight decay (AdamW)", + "Damps directions the data does not constrain. Use AdamW, not Adam with an L2 term " + "in the loss — they are not equivalent under an adaptive optimizer.", "ch07, ch08"), + ("label-smoothing", "Label smoothing", + "Reduces over-confidence on the training targets; cheap and usually harmless.", + "ch07 (noise injection on labels)"), + ("dropout", "Dropout", + "Approximate ensembling. Note the interaction with batch norm, and that modern " + "large stacks regularize far more lightly than 2016 practice.", "ch07"), + ("parameter-sharing", "Structural parameter sharing", + "The strongest regularizer: remove parameters rather than penalize them. Only valid " + "when a real invariance exists (convolution, weight tying).", "ch07, ch09"), + ("smaller-model", "Reduce model capacity", + "Classical advice from the underparameterized side of the U-curve. In the " + "overparameterized regime this is ranked last, not first.", "ch05"), +] + +# Parameters-per-example ratio bands. Approximate regime markers, not a threshold +# theorem — the interpolation threshold depends on the task, the architecture and the +# label noise, and cannot be read off a parameter count alone. +UNDERPARAMETERIZED_MAX = 1.0 +OVERPARAMETERIZED_MIN = 10.0 + + +def classify_regime(params: int, examples: int, + under_max: float = UNDERPARAMETERIZED_MAX, + over_min: float = OVERPARAMETERIZED_MIN) -> tuple[str, float, str]: + ratio = params / examples + if ratio < under_max: + regime = "underparameterized" + note = ("Fewer parameters than training examples. The classical U-curve applies " + "directly here: reducing capacity is a legitimate response to overfitting.") + elif ratio < over_min: + regime = "near-interpolation" + note = ("Roughly at the interpolation threshold, where the classical curve peaks " + "and double descent begins. This is the worst place to sit: both more " + "capacity and less capacity can improve test error, so measure rather " + "than reason.") + else: + regime = "overparameterized" + note = ("Many more parameters than examples. The classical 'shrink the model' " + "advice is not reliable here — double descent means a larger model with " + "more data or stronger regularization often generalizes better.") + return regime, ratio, note + + +def plan(params: int, examples: int, train_error: float, val_error: float, + applied: set[str], target_error: float | None, + overfit_rel_gap: float, + under_max: float = UNDERPARAMETERIZED_MAX, + over_min: float = OVERPARAMETERIZED_MIN) -> dict: + regime, ratio, regime_note = classify_regime(params, examples, under_max, over_min) + denom = max(abs(train_error), 1e-6) + gap = val_error - train_error + rel_gap = gap / denom + + if target_error is not None and train_error > target_error * 1.15: + verdict = "UNDERFIT" + headline = ("Training error is above target — capacity or optimization is the " + "bottleneck. More data cannot help yet.") + actions = [ + ("tune-lr-schedule", "Tune learning rate and schedule first", + "Usually dominates the choice of optimizer family. Warmup plus cosine decay " + "is the modern default for deep stacks.", "ch08"), + ("check-init", "Check initialization scale (He / Xavier)", + "Bad initialization is a common silent cause of a model that will not fit.", + "ch08"), + ("remove-regularization", "Remove regularization you already added", + "Every regularizer you applied is buying variance reduction you cannot " + "currently afford.", "ch07"), + ("add-capacity", "Add capacity (width or depth)", + "Only after the three above — an optimization problem does not respond to " + "more parameters.", "ch05, ch06"), + ] + elif rel_gap > overfit_rel_gap: + verdict = "OVERFIT" + headline = (f"Validation error exceeds training error by {rel_gap:.0%} of the " + "training error — spend from the regularization budget.") + ladder = [item for item in LADDER if item[0] not in applied] + if regime == "underparameterized": + actions = ladder + else: + # Push smaller-model to the end and say why. + actions = ([item for item in ladder if item[0] != "smaller-model"] + + [item for item in ladder if item[0] == "smaller-model"]) + else: + verdict = "BALANCED" + headline = ("The gap is within tolerance. Neither more capacity nor more " + "regularization is indicated by these numbers.") + actions = [ + ("verify-metric", "Verify the metric is the one you care about", + "A balanced fit on the wrong proxy is still the wrong model.", "ch11"), + ("check-splits", "Confirm the split is clean and the result holds across seeds", + "A small dataset with one seed is not a measurement.", "ch05, ch11"), + ] + + return { + "verdict": verdict, + "headline": headline, + "regime": regime, + "regime_note": regime_note, + "params": params, + "train_examples": examples, + "params_per_example": round(ratio, 3), + "train_error": train_error, + "val_error": val_error, + "gap": round(gap, 6), + "relative_gap": round(rel_gap, 4), + "target_error": target_error, + "already_applied": sorted(applied), + "actions": [ + {"id": a[0], "action": a[1], "why": a[2], "chapter": a[3]} + for a in actions + ], + "thresholds": { + "overfit_rel_gap": overfit_rel_gap, + "underparameterized_max": under_max, + "overparameterized_min": over_min, + }, + "double_descent_caveat": ( + regime != "underparameterized" and verdict == "OVERFIT" + ), + } + + +def render(result: dict) -> str: + lines = [ + "CAPACITY & REGULARIZATION PLAN", + "=" * 70, + f"Verdict : {result['verdict']}", + f" {result['headline']}", + "", + f"Regime : {result['regime']} " + f"({result['params_per_example']} params per training example)", + f" {result['regime_note']}", + "", + f"Train err : {result['train_error']} Val err: {result['val_error']} " + f"gap: {result['gap']} ({result['relative_gap']:.0%} of train error)", + ] + if result["already_applied"]: + lines.append(f"Applied : {', '.join(result['already_applied'])} (excluded below)") + lines += ["", "Do these in order:", "-" * 70] + for index, action in enumerate(result["actions"], start=1): + lines.append(f"{index}. {action['action']} [{action['chapter']}]") + lines.append(f" {action['why']}") + lines.append("-" * 70) + if result["double_descent_caveat"]: + lines.append( + "Double-descent caveat: 'reduce capacity' is ranked LAST here because this " + "model is at or past the interpolation threshold, where the classical " + "U-curve advice is unreliable (Belkin 2019, Nakkiran 2020 — both post-date " + "the book). See references/book_to_2026_delta.md." + ) + lines.append("Change one thing per experiment and re-measure the gap. (ch11)") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Turn a measured generalization gap into an ordered action plan.", + epilog="Exit codes: 0 balanced · 1 action recommended · 4 inconsistent inputs.", + ) + parser.add_argument("--params", type=int, help="trainable parameter count") + parser.add_argument("--train-examples", type=int, help="number of training examples") + parser.add_argument("--train-error", type=float, + help="training error or loss (same units as --val-error)") + parser.add_argument("--val-error", type=float, help="validation error or loss") + parser.add_argument("--target-error", type=float, + help="the value that would count as success; without it, " + "underfitting cannot be distinguished from convergence") + parser.add_argument("--applied", default="", + help="comma-separated regularizers already in use, from: " + + ",".join(item[0] for item in LADDER)) + parser.add_argument("--overfit-rel-gap", type=float, default=0.30, + help="relative gap above which the fit counts as overfitting " + "(default: 0.30)") + parser.add_argument("--underparameterized-max", type=float, + default=UNDERPARAMETERIZED_MAX, + help="params-per-example below which the classical U-curve " + f"applies directly (default: {UNDERPARAMETERIZED_MAX})") + parser.add_argument("--overparameterized-min", type=float, + default=OVERPARAMETERIZED_MIN, + help="params-per-example above which double descent makes " + "'shrink the model' unreliable " + f"(default: {OVERPARAMETERIZED_MIN}). These are heuristic " + "bands, not a threshold theorem — the interpolation point " + "depends on task, architecture and label noise.") + parser.add_argument("--output", choices=("text", "json"), default="text") + parser.add_argument("--sample", action="store_true", + help="run against a built-in overparameterized example") + args = parser.parse_args(argv) + + if args.sample: + args.params, args.train_examples = 12_000_000, 50_000 + args.train_error, args.val_error = 0.01, 0.22 + args.applied = "early-stopping" + + required = (args.params, args.train_examples, args.train_error, args.val_error) + if any(value is None for value in required): + parser.error("--params, --train-examples, --train-error and --val-error are all " + "required (or use --sample)") + if args.params <= 0 or args.train_examples <= 0: + parser.error("--params and --train-examples must be positive") + if args.underparameterized_max >= args.overparameterized_min: + # Overlapping bands silently mis-class the regime, and the cost is not + # cosmetic: an overparameterized model reported as underparameterized ranks + # "shrink the model" FIRST, inverting the double-descent correction this + # tool exists to apply. + parser.error( + f"--underparameterized-max ({args.underparameterized_max}) must be less " + f"than --overparameterized-min ({args.overparameterized_min}); the bands " + "are ordered and must not overlap" + ) + + known = {item[0] for item in LADDER} + applied = {token.strip() for token in args.applied.split(",") if token.strip()} + unknown = applied - known + if unknown: + parser.error(f"unknown --applied value(s): {', '.join(sorted(unknown))}; " + f"choose from {', '.join(sorted(known))}") + + if args.val_error < args.train_error - 0.05 * max(abs(args.train_error), 1e-6): + payload = { + "status": "inconsistent_input", + "reason": "validation error is materially below training error", + "explanation": "This usually means a leaky split, a validation set that is " + "easier than the training set, or regularization active at " + "train time but not at eval (dropout, augmentation). Fix the " + "measurement before acting on it.", + "chapter": "ch05, ch11", + } + if args.output == "json": + print(json.dumps(payload, indent=2)) + else: + print("INCONSISTENT INPUT — " + payload["reason"]) + print(payload["explanation"]) + return 4 + + result = plan(args.params, args.train_examples, args.train_error, args.val_error, + applied, args.target_error, args.overfit_rel_gap, + args.underparameterized_max, args.overparameterized_min) + + if args.output == "json": + print(json.dumps(result, indent=2)) + else: + print(render(result)) + return 0 if result["verdict"] == "BALANCED" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/engineering/deep-learning-book/skills/deep-learning-book/scripts/model_arithmetic.py b/engineering/deep-learning-book/skills/deep-learning-book/scripts/model_arithmetic.py new file mode 100644 index 000000000..3de290d32 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/scripts/model_arithmetic.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +"""model_arithmetic.py — parameters, FLOPs and activation memory for a layer stack. + +Chapter 6 makes the point that the real cost of depth in training is activation memory: +the backward pass needs the forward activations, which is why gradient checkpointing +trades compute for memory. Chapter 9 makes the receptive-field and parameter-sharing +arithmetic explicit. This tool does that arithmetic for a declared stack, and — the part +that catches real bugs — refuses to continue when one layer's output shape cannot feed +the next. + +Shapes are per example, excluding the batch dimension. FLOPs are per example, per +forward pass; a training step costs roughly 3x a forward pass (forward + backward). + +Layer types: input, linear, conv2d, pool2d, flatten, embedding, layernorm, activation, +dropout, mha (multi-head self-attention), lstm, gru. + +A linear layer on a 2-D (seq, features) input is treated as position-wise: one weight +matrix shared across all positions, as in a transformer feedforward block. Parameters do +not scale with sequence length; compute does. Flatten first only when you really mean a +dense layer over the whole flattened sequence — that is a different layer, and its +parameter count is seq_len times larger. + +conv2d "same" padding follows TensorFlow/Keras SAME: output is ceil(H / stride), with +any needed padding split across the two sides (and the extra pixel going to the bottom +and right at even kernel sizes). PyTorch's padding='same' is symmetric-only and rejects +a stride other than 1, so a strided PyTorch layer will not match this row — declare it +as "valid" with the padding you actually apply if you need that case exactly. + +Standard library only. No frameworks, no network calls. + +Exit codes: + 0 the stack is consistent and the report was produced + 2 usage error (argparse) + 4 the spec could not be parsed (bad JSON, unknown layer type, missing field) + 5 shape mismatch between consecutive layers — the offending layer is named +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys + +BYTES_PER_ELEMENT = {"fp32": 4, "tf32": 4, "fp16": 2, "bf16": 2, "fp8": 1} + +# A multiply-accumulate is counted as 2 FLOPs. --convention mac reports MACs instead. +FLOPS_PER_MAC = 2 + + +class SpecError(ValueError): + """The spec is malformed: bad JSON, unknown layer, or a missing field.""" + + +class ShapeError(ValueError): + """A layer cannot consume the shape the previous layer produced.""" + + +def _require(layer: dict, key: str, index: int): + if not isinstance(layer, dict): + raise SpecError(f"layer {index} is {type(layer).__name__}, not an object") + if key not in layer: + raise SpecError(f"layer {index} ({layer.get('type', '?')}) is missing '{key}'") + return layer[key] + + +def _positive_int(layer: dict, key: str, index: int, default: int | None = None) -> int: + """Read an integer field, rejecting non-numeric and non-positive values. + + Without this, a non-numeric "filters" raised ValueError and a zero "stride" or + "groups" raised ZeroDivisionError — both escaping as tracebacks rather than the + documented exit 4. + """ + raw = layer.get(key, default) if isinstance(layer, dict) else default + if raw is None: + raise SpecError(f"layer {index} ({layer.get('type', '?')}) is missing '{key}'") + try: + value = int(raw) + except (TypeError, ValueError): + raise SpecError( + f"layer {index} ({layer.get('type', '?')}): '{key}' must be an integer, " + f"got {raw!r}" + ) from None + if value <= 0: + raise SpecError( + f"layer {index} ({layer.get('type', '?')}): '{key}' must be positive, got {value}" + ) + return value + + +def _prod(shape: tuple[int, ...]) -> int: + total = 1 + for dim in shape: + total *= dim + return total + + +def step(layer: dict, shape: tuple[int, ...], index: int) -> tuple[tuple[int, ...], int, int]: + """Return (output_shape, parameters, macs) for one layer given its input shape.""" + kind = _require(layer, "type", index) + + if kind == "input": + raw_shape = _require(layer, "shape", index) + if not isinstance(raw_shape, (list, tuple)) or not raw_shape: + raise SpecError(f"layer {index} (input): 'shape' must be a non-empty list") + dims = [] + for dim in raw_shape: + try: + dim = int(dim) + except (TypeError, ValueError): + raise SpecError( + f"layer {index} (input): shape entries must be integers, got {dim!r}" + ) from None + if dim <= 0: + raise SpecError(f"layer {index} (input): shape entries must be positive") + dims.append(dim) + return tuple(dims), 0, 0 + + if kind == "linear": + units = _positive_int(layer, "units", index) + bias = bool(layer.get("bias", True)) + if len(shape) == 2: + # Per-token (position-wise) linear over a (seq, features) sequence: one + # weight matrix shared across positions, exactly like a transformer FFN + # projection. Parameters do NOT scale with sequence length; compute does. + # Flattening instead would multiply the parameter count by seq_len, which + # is a different layer and almost never the intended one. + seq, features = shape + params = features * units + (units if bias else 0) + return (seq, units), params, seq * features * units + if len(shape) != 1: + raise ShapeError( + f"layer {index} (linear) needs a 1-D or 2-D input, got {shape}. " + "Insert a flatten layer to collapse a feature map into one vector." + ) + params = shape[0] * units + (units if bias else 0) + return (units,), params, shape[0] * units + + if kind == "conv2d": + filters = _positive_int(layer, "filters", index) + kernel = _positive_int(layer, "kernel", index) + stride = _positive_int(layer, "stride", index, 1) + padding = layer.get("padding", "same") + if len(shape) != 3: + raise ShapeError( + f"layer {index} (conv2d) needs a 3-D input (C, H, W), got {shape}" + ) + channels, height, width = shape + if padding == "same": + # TensorFlow/Keras SAME convention; see the module docstring for how this + # differs from PyTorch's stride-1-only padding='same'. + out_h, out_w = math.ceil(height / stride), math.ceil(width / stride) + elif padding == "valid": + out_h = (height - kernel) // stride + 1 + out_w = (width - kernel) // stride + 1 + else: + raise SpecError(f"layer {index} (conv2d): padding must be 'same' or 'valid'") + if out_h <= 0 or out_w <= 0: + raise ShapeError( + f"layer {index} (conv2d) with kernel {kernel} and stride {stride} " + f"reduces {height}x{width} to {out_h}x{out_w} — the kernel is larger " + "than the feature map." + ) + groups = _positive_int(layer, "groups", index, 1) + if channels % groups or filters % groups: + raise SpecError( + f"layer {index} (conv2d): groups={groups} does not divide " + f"in_channels={channels} and filters={filters}" + ) + bias = bool(layer.get("bias", True)) + params = (kernel * kernel * (channels // groups) * filters) + (filters if bias else 0) + macs = kernel * kernel * (channels // groups) * filters * out_h * out_w + return (filters, out_h, out_w), params, macs + + if kind == "pool2d": + size = _positive_int(layer, "size", index, 2) + stride = _positive_int(layer, "stride", index, size) + if len(shape) != 3: + raise ShapeError(f"layer {index} (pool2d) needs a 3-D input, got {shape}") + channels, height, width = shape + out_h = (height - size) // stride + 1 + out_w = (width - size) // stride + 1 + if out_h <= 0 or out_w <= 0: + raise ShapeError( + f"layer {index} (pool2d) reduces {height}x{width} to {out_h}x{out_w}" + ) + return (channels, out_h, out_w), 0, 0 + + if kind == "flatten": + return (_prod(shape),), 0, 0 + + if kind == "embedding": + vocab = _positive_int(layer, "vocab", index) + dim = _positive_int(layer, "dim", index) + seq = _positive_int(layer, "seq_len", index, + default=shape[0] if shape else 1) + return (seq, dim), vocab * dim, 0 # a lookup, not a matmul + + if kind == "layernorm": + features = shape[-1] + return shape, 2 * features, 0 + + if kind in ("activation", "dropout"): + return shape, 0, 0 + + if kind == "mha": + # Multi-head self-attention over a (seq, d_model) input. + if len(shape) != 2: + raise ShapeError( + f"layer {index} (mha) needs a 2-D input (seq_len, d_model), got {shape}" + ) + seq, d_model = shape + heads = _positive_int(layer, "heads", index, 8) + if d_model % heads: + raise SpecError( + f"layer {index} (mha): d_model={d_model} is not divisible by heads={heads}" + ) + # 4 projections (Q, K, V, O), each d_model x d_model. + params = 4 * d_model * d_model + (4 * d_model if layer.get("bias", True) else 0) + proj_macs = 4 * seq * d_model * d_model + # Scores (seq x seq x d_model) and the weighted value sum, both quadratic in seq. + attn_macs = 2 * seq * seq * d_model + return shape, params, proj_macs + attn_macs + + if kind in ("lstm", "gru"): + if len(shape) != 2: + raise ShapeError( + f"layer {index} ({kind}) needs a 2-D input (seq_len, features), got {shape}" + ) + seq, features = shape + units = _positive_int(layer, "units", index) + gates = 4 if kind == "lstm" else 3 + params = gates * (features * units + units * units + 2 * units) + macs = seq * gates * (features * units + units * units) + out = (seq, units) if layer.get("return_sequences", True) else (units,) + return out, params, macs + + raise SpecError(f"layer {index}: unknown layer type {kind!r}") + + +def analyse(spec: dict, dtype: str, convention: str) -> dict: + if not isinstance(spec, dict): + raise SpecError(f"spec must be a JSON object, got {type(spec).__name__}") + layers = spec.get("layers") + if not isinstance(layers, list) or not layers: + raise SpecError("spec must contain a non-empty 'layers' list") + for index, layer in enumerate(layers): + if not isinstance(layer, dict): + raise SpecError(f"layer {index} is {type(layer).__name__}, not an object") + if layers[0].get("type") != "input": + raise SpecError("the first layer must be of type 'input'") + + width = BYTES_PER_ELEMENT[dtype] + shape: tuple[int, ...] = () + rows = [] + total_params = 0 + total_macs = 0 + total_activations = 0 + + for index, layer in enumerate(layers): + shape, params, macs = step(layer, shape, index) + activations = _prod(shape) + total_params += params + total_macs += macs + # The input layer's tensor is not a stored intermediate activation. + if index > 0: + total_activations += activations + rows.append({ + "index": index, + "type": layer["type"], + "name": layer.get("name", layer["type"]), + "output_shape": list(shape), + "parameters": params, + "macs": macs, + "flops": macs * FLOPS_PER_MAC, + "activation_elements": activations, + }) + + compute = total_macs if convention == "mac" else total_macs * FLOPS_PER_MAC + return { + "dtype": dtype, + "convention": convention, + "layers": rows, + "totals": { + "parameters": total_params, + "parameter_bytes": total_params * width, + "forward_macs": total_macs, + "forward_flops": total_macs * FLOPS_PER_MAC, + "reported_compute": compute, + "activation_elements_per_example": total_activations, + "activation_bytes_per_example": total_activations * width, + }, + "notes": [ + "Shapes and costs are per example; the batch dimension is excluded.", + "A training step costs roughly 3x the forward FLOPs (forward + backward).", + "Activation memory is the training-time cost of depth (ch06). Gradient " + "checkpointing trades compute for it.", + "Optimizer state is extra: Adam/AdamW holds two moments per parameter, so " + "budget ~3x parameter bytes for weights plus state in fp32.", + "Attention cost is quadratic in sequence length — visible in the mha row.", + ], + } + + +SAMPLE_SPEC = { + "name": "small convnet, CIFAR-shaped", + "layers": [ + {"type": "input", "shape": [3, 32, 32]}, + {"type": "conv2d", "filters": 32, "kernel": 3, "padding": "same"}, + {"type": "activation", "name": "relu"}, + {"type": "pool2d", "size": 2}, + {"type": "conv2d", "filters": 64, "kernel": 3, "padding": "same"}, + {"type": "activation", "name": "relu"}, + {"type": "pool2d", "size": 2}, + {"type": "flatten"}, + {"type": "linear", "units": 128}, + {"type": "activation", "name": "relu"}, + {"type": "dropout"}, + {"type": "linear", "units": 10}, + ], +} + + +def human(value: int) -> str: + for unit, scale in (("G", 1e9), ("M", 1e6), ("K", 1e3)): + if value >= scale: + return f"{value / scale:.2f}{unit}" + return str(value) + + +def render(result: dict) -> str: + lines = [ + "MODEL ARITHMETIC", + "=" * 78, + f"{'#':>2} {'layer':<12} {'output shape':<18} {'params':>12} " + f"{'MFLOPs':>10} {'act.elts':>10}", + "-" * 78, + ] + for row in result["layers"]: + shape = "x".join(str(dim) for dim in row["output_shape"]) + lines.append( + f"{row['index']:>2} {row['type']:<12} {shape:<18} " + f"{row['parameters']:>12,} {row['flops'] / 1e6:>10.2f} " + f"{row['activation_elements']:>10,}" + ) + totals = result["totals"] + lines += [ + "-" * 78, + f"Parameters : {totals['parameters']:,} " + f"({human(totals['parameter_bytes'])}B at {result['dtype']})", + f"Forward FLOPs/ex : {human(totals['forward_flops'])} " + f"({human(totals['forward_macs'])} MACs)", + f"Training step (~3x) : {human(totals['forward_flops'] * 3)} FLOPs/ex", + f"Activations/ex : {totals['activation_elements_per_example']:,} elements " + f"({human(totals['activation_bytes_per_example'])}B at {result['dtype']})", + "", + ] + lines.append(f"At batch 128: activations ≈ " + f"{human(totals['activation_bytes_per_example'] * 128)}B") + lines.append("") + for note in result["notes"]: + lines.append(f"· {note}") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Compute parameters, FLOPs and activation memory for a layer stack.", + epilog="Exit codes: 0 ok · 4 bad spec · 5 shape mismatch.", + ) + parser.add_argument("--spec", help="path to a JSON layer spec") + parser.add_argument("--spec-sample", action="store_true", + help="print the built-in sample spec and exit") + parser.add_argument("--dtype", choices=sorted(BYTES_PER_ELEMENT), default="fp32") + parser.add_argument("--convention", choices=("flop", "mac"), default="flop", + help="report FLOPs (1 MAC = 2 FLOPs) or raw MACs") + parser.add_argument("--output", choices=("text", "json"), default="text") + parser.add_argument("--sample", action="store_true", + help="analyse the built-in sample spec") + args = parser.parse_args(argv) + + if args.spec_sample: + print(json.dumps(SAMPLE_SPEC, indent=2)) + return 0 + + if args.sample: + spec = SAMPLE_SPEC + elif args.spec: + try: + with open(args.spec, encoding="utf-8") as handle: + spec = json.load(handle) + except OSError as error: + print(f"cannot read spec: {error}", file=sys.stderr) + return 4 + except json.JSONDecodeError as error: + print(f"spec is not valid JSON: {error}", file=sys.stderr) + return 4 + else: + parser.error("--spec is required (or use --sample / --spec-sample)") + + try: + result = analyse(spec, args.dtype, args.convention) + except ShapeError as error: + print(f"SHAPE MISMATCH — {error}", file=sys.stderr) + print("A shape mismatch is a modelling error, not a typing error (ch02).", + file=sys.stderr) + return 5 + except SpecError as error: + print(f"BAD SPEC — {error}", file=sys.stderr) + return 4 + + if args.output == "json": + print(json.dumps(result, indent=2)) + else: + print(render(result)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/engineering/deep-learning-book/skills/deep-learning-book/scripts/reading_path_planner.py b/engineering/deep-learning-book/skills/deep-learning-book/scripts/reading_path_planner.py new file mode 100644 index 000000000..436c7afb5 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/scripts/reading_path_planner.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""reading_path_planner.py — turn a goal into an ordered, prerequisite-closed reading path. + +The book's part order is not its dependency order. Read front-to-back and you spend +weeks in Chapters 2-4 before touching a network; skip Part I entirely and Chapter 8 +becomes unreadable. This tool takes a goal and a background, resolves the *hard* +prerequisites of the chapters that goal needs, orders them, and prices the result +in weeks at your stated pace. + +It refuses two things rather than guessing: + * a goal whose subject the 2016 book does not cover at all (exit 3) — it names what + covers it instead, rather than inventing the book's position on RLHF; + * a goal too vague to route (exit 4) — it prints the questions that would fix it. + +Deterministic keyword scoring. Standard library only. No network calls. + +Exit codes: + 0 a path was produced + 2 usage error (argparse) + 3 goal is outside the book's scope + 4 goal could not be routed — forcing questions printed +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys + +# --------------------------------------------------------------------------- data + +# Hard prerequisites only: skipping one makes the target unreadable, not merely +# harder. Soft prerequisites live in references/prerequisite_map.md and are +# deliberately not enforced here. +PREREQS: dict[int, tuple[int, ...]] = { + 1: (), + 2: (), + 3: (), + 4: (2,), + 5: (3,), + 6: (3,), + 7: (4, 5), + 8: (2, 4), + 9: (6,), + 10: (6,), + 11: (5,), + 12: (6,), + 13: (5,), + 14: (13,), + 15: (14,), + 16: (3,), + 17: (16,), + 18: (16, 17), + 19: (3,), + 20: (13, 19), +} + +TITLES: dict[int, str] = { + 1: "Introduction", + 2: "Linear Algebra", + 3: "Probability and Information Theory", + 4: "Numerical Computation", + 5: "Machine Learning Basics", + 6: "Deep Feedforward Networks", + 7: "Regularization for Deep Learning", + 8: "Optimization for Training Deep Models", + 9: "Convolutional Networks", + 10: "Sequence Modeling: Recurrent and Recursive Nets", + 11: "Practical Methodology", + 12: "Applications", + 13: "Linear Factor Models", + 14: "Autoencoders", + 15: "Representation Learning", + 16: "Structured Probabilistic Models for Deep Learning", + 17: "Monte Carlo Methods", + 18: "Confronting the Partition Function", + 19: "Approximate Inference", + 20: "Deep Generative Models", +} + +# Planning assumptions, not measurements. Part I and Part III chapters carry higher +# derivation density per page, which is why they cost more than their length suggests. +# Exposed in --output json so a reader can recalibrate against their own first chapter. +BASE_HOURS: dict[int, float] = { + 1: 1.5, 2: 5.0, 3: 6.0, 4: 4.5, 5: 9.0, + 6: 6.0, 7: 6.0, 8: 8.0, 9: 6.0, 10: 7.0, 11: 4.0, 12: 5.0, + 13: 4.0, 14: 4.5, 15: 5.0, 16: 6.0, 17: 5.0, 18: 7.0, 19: 6.0, 20: 8.0, +} + +# Background multipliers on the hour estimate. +BACKGROUNDS: dict[str, tuple[float, str]] = { + "none": (1.6, "little linear algebra or probability — Part I is real work, not review"), + "math": (0.8, "comfortable with linear algebra and probability, new to ML"), + "applied": (1.0, "ships models, wants the theory underneath"), + "research": (0.7, "reads papers in the field; the book is filling gaps"), +} + +# Destination lanes. Each is (label, target chapters, note). Scored by keyword hits. +LANES: dict[str, dict] = { + "practitioner": { + "label": "Applied practitioner — train models that work", + "targets": (5, 6, 7, 8, 11), + "keywords": ("train", "training", "practical", "ship", "production", "apply", + "applied", "practitioner", "debug", "tune", "tuning", "improve", + "overfit", "underfit", "hyperparameter", "baseline", "workflow"), + "note": "Chapters 5 and 11 first; they pay off before you finish Part I.", + }, + "vision": { + "label": "Computer vision — convolutional models", + "targets": (6, 7, 8, 9, 12), + "keywords": ("vision", "image", "convolution", "convolutional", "cnn", "conv", + "segmentation", "detection", "pooling", "receptive"), + "note": "Ch 9's prior-vs-data argument is what explains ViT's data hunger later.", + }, + "sequence": { + "label": "Sequence modelling — recurrence, gradients through time, attention", + "targets": (6, 8, 10, 12), + "keywords": ("sequence", "rnn", "lstm", "gru", "recurrent", "time series", + "timeseries", "nlp", "language", "text", "speech", "translation", + "transformer", "attention"), + "note": "Read Ch 10 for the gradient analysis, not the architecture advice — " + "see references/book_to_2026_delta.md before applying it.", + }, + "generative": { + "label": "Generative modelling — the Part III chain", + "targets": (13, 14, 16, 17, 18, 19, 20), + "keywords": ("generative", "vae", "gan", "diffusion", "autoencoder", "sampling", + "mcmc", "boltzmann", "latent", "variational", "elbo", "density", + "score matching", "partition"), + "note": "Ch 18 is the chapter whose ideas became diffusion. Do not skip it to " + "reach Ch 20.", + }, + "representation": { + "label": "Representation and self-supervised learning", + "targets": (5, 13, 14, 15), + "keywords": ("representation", "embedding", "feature", "features", "transfer", + "pretrain", "pretraining", "self-supervised", "unsupervised", + "disentangle", "disentangled", "probe"), + "note": "The bet in Ch 15 was right; every method it lists was replaced. " + "Read it with the delta reference open.", + }, + "foundations": { + "label": "Mathematical foundations — the machinery under everything else", + "targets": (2, 3, 4, 5), + "keywords": ("math", "mathematics", "foundation", "foundations", "linear algebra", + "probability", "statistics", "theory", "fundamentals", "basics", + "prerequisite", "prerequisites", "refresher", "interview"), + "note": "Pair with a dedicated linear algebra text; Part I is reference " + "material, not pedagogy.", + }, + "optimization": { + "label": "Optimization — why training stalls, diverges, or crawls", + "targets": (2, 4, 8, 11), + "keywords": ("optimization", "optimizer", "sgd", "adam", "momentum", "gradient", + "converge", "convergence", "diverge", "learning rate", "saddle", + "initialization", "batch norm", "normalization", "clipping"), + "note": "Ch 8 is unreadable without Ch 2 and 4. This is the one place the " + "prerequisite is genuinely hard.", + }, + "complete": { + "label": "Complete read — all twenty chapters", + "targets": tuple(range(1, 21)), + "keywords": ("everything", "whole book", "entire book", "all chapters", + "cover to cover", "complete", "full read", "read the book", + "start to finish"), + "note": "Even here, read Ch 5 and Ch 11 early rather than in numeric order.", + }, +} + +# Subjects the 2016 book does not cover. Naming these beats improvising its position. +OUT_OF_SCOPE: dict[str, str] = { + "rlhf": "RLHF / preference tuning — published 2017+; nothing in this book covers it.", + "dpo": "Direct preference optimization — 2023; outside the book entirely.", + "llm": "Large language model training and serving — the book predates it; see " + "engineering/llm-cost-optimizer and references/book_to_2026_delta.md.", + "prompt": "Prompting and in-context learning — post-dates the book.", + "agent": "Agentic systems — outside the book; see engineering/agent-harness.", + "mlops": "MLOps tooling and deployment — see engineering-team/senior-ml-engineer.", + "fine-tun": "Fine-tuning of pretrained foundation models — the book's transfer " + "learning section (ch15) is the nearest thing, and it is not the same.", + "lora": "Parameter-efficient fine-tuning (LoRA and relatives) — 2021+.", + "rag": "Retrieval-augmented generation — outside the book.", + "mamba": "State-space models — 2021+; ch10's gradient analysis is the relevant " + "background the book does provide.", + "fairness": "Fairness, bias auditing and model governance — not treated.", + "reinforcement": "Reinforcement learning — mentioned only in passing (ch12).", +} + +# Tokens whose real surface forms a word-boundary match would otherwise miss. +# Everything else matches itself, optionally pluralized. +SURFACE_FORMS: dict[str, tuple[str, ...]] = { + "fine-tun": ("fine-tuning", "fine-tune", "fine-tuned", "finetuning", "finetune"), + "prompt": ("prompt", "prompting", "prompts"), + "agent": ("agent", "agents", "agentic"), +} + + +def _matches(token: str, text: str) -> bool: + """True when token appears in text as a whole word (optionally pluralized). + + Substring matching is wrong here and was a real defect: "rag" appears inside + "storage", "lora" inside "exploratory", "conv" inside "converge", and "text" + inside "context" — each one producing a confident false refusal or a wrong lane. + """ + for form in SURFACE_FORMS.get(token, (token,)): + # Plain -s only. An -es branch collided with unrelated words: "rag" + "es" + # matches the standalone word "rages", so a goal about overfitting was + # refused as out-of-scope RAG work. No token here needs an -es plural — + # every one ending in s/x/z/ch/sh is already plural or non-count — so any + # irregular form belongs in SURFACE_FORMS, spelled out. + if re.search(rf"\b{re.escape(form)}s?\b", text): + return True + return False + + +# --------------------------------------------------------------------------- logic + + +def close_prerequisites(targets: tuple[int, ...]) -> list[int]: + """Return targets plus every hard prerequisite, in ascending chapter order.""" + needed: set[int] = set() + stack = list(targets) + while stack: + chapter = stack.pop() + if chapter in needed: + continue + needed.add(chapter) + stack.extend(PREREQS.get(chapter, ())) + return sorted(needed) + + +def order_path(chapters: list[int]) -> list[int]: + """Order chapters so every hard prerequisite precedes its dependent. + + Among chapters whose prerequisites are already satisfied, the cheapest + high-value chapter goes first: ch05 and ch11 are promoted because their + vocabulary is reused everywhere and they are actionable immediately. + """ + promoted = {5: -2, 11: -1} + remaining = set(chapters) + placed: list[int] = [] + while remaining: + ready = [c for c in remaining if all(p in placed for p in PREREQS.get(c, ()))] + if not ready: # unreachable with the current acyclic table; fail loudly if it changes + raise RuntimeError("prerequisite cycle in PREREQS") + ready.sort(key=lambda c: (promoted.get(c, 0), c)) + nxt = ready[0] + placed.append(nxt) + remaining.discard(nxt) + return placed + + +def score_lanes(goal: str) -> list[tuple[str, int]]: + """Score every lane by keyword hits in the goal text, best first. + + Ties are broken by keyword specificity — the lane whose longest matched + keyword is longest wins — because an equal hit count between a generic term + and a discriminating one should not be settled by luck. "train a transformer" + hits `practitioner` on "train" and `sequence` on "transformer", one each; the + longer, more specific match is the one that names the subject. Lane key is the + final tie-break so the ordering stays deterministic. + """ + text = goal.lower() + scored = [] + for key, lane in LANES.items(): + matched = [kw for kw in lane["keywords"] if _matches(kw, text)] + if matched: + scored.append((key, len(matched), max(len(kw) for kw in matched))) + scored.sort(key=lambda row: (-row[1], -row[2], row[0])) + return [(key, hits) for key, hits, _ in scored] + + +def out_of_scope_hits(goal: str) -> list[str]: + text = goal.lower() + return [note for token, note in OUT_OF_SCOPE.items() if _matches(token, text)] + + +def plan(goal: str, background: str, hours_per_week: float, + include_intro: bool) -> dict: + lane_key, _ = score_lanes(goal)[0] + lane = LANES[lane_key] + targets = lane["targets"] + chapters = close_prerequisites(targets) + if include_intro and 1 not in chapters: + # ch01 is context, not a prerequisite of anything, so it never arrives via + # closure — the flag is the only way to reach it outside the complete lane. + # (An earlier form of this filtered ch01 *out*, which was inert: nothing + # depends on ch01, and the one lane that targets it skipped the filter.) + chapters.append(1) + ordered = order_path(chapters) + + multiplier, background_note = BACKGROUNDS[background] + entries = [] + for chapter in ordered: + hours = round(BASE_HOURS[chapter] * multiplier, 1) + entries.append({ + "chapter": chapter, + "title": TITLES[chapter], + "role": "target" if chapter in targets else "prerequisite", + "hours": hours, + "file": f"chapters/ch{chapter:02d}-*.md", + "url": "https://www.deeplearningbook.org/", + }) + + total_hours = round(sum(e["hours"] for e in entries), 1) + weeks = round(total_hours / hours_per_week, 1) if hours_per_week > 0 else None + skipped = [c for c in range(1, 21) if c not in ordered] + + return { + "goal": goal, + "lane": lane_key, + "lane_label": lane["label"], + "background": background, + "background_note": background_note, + "hours_per_week": hours_per_week, + "path": entries, + "total_hours": total_hours, + "estimated_weeks": weeks, + "skipped_chapters": skipped, + "note": lane["note"], + "assumptions": { + "base_hours_per_chapter": BASE_HOURS, + "background_multiplier": multiplier, + "basis": "planning heuristic, not measurement — recalibrate after chapter one", + }, + "hard_prerequisites_applied": { + str(c): list(PREREQS[c]) for c in ordered if PREREQS.get(c) + }, + } + + +# --------------------------------------------------------------------------- output + + +def render(result: dict) -> str: + lines = [ + "READING PATH", + "=" * 64, + f"Goal : {result['goal']}", + f"Lane : {result['lane_label']}", + f"Background : {result['background']} — {result['background_note']}", + f"Budget : {result['total_hours']} h at {result['hours_per_week']} h/week" + f" ≈ {result['estimated_weeks']} weeks", + "", + f"{'#':>3} {'ch':>4} {'hrs':>5} role title", + "-" * 64, + ] + for index, entry in enumerate(result["path"], start=1): + lines.append( + f"{index:>3} ch{entry['chapter']:02d} {entry['hours']:>5} " + f"{entry['role']:<12} {entry['title']}" + ) + lines.append("-" * 64) + if result["hard_prerequisites_applied"]: + lines.append("Hard prerequisites pulled in:") + for chapter, prereqs in result["hard_prerequisites_applied"].items(): + names = ", ".join(f"ch{p:02d}" for p in prereqs) + lines.append(f" ch{int(chapter):02d} needs {names}") + if result["skipped_chapters"]: + skipped = ", ".join(f"ch{c:02d}" for c in result["skipped_chapters"]) + lines.append(f"Not in this path: {skipped}") + lines.append("") + lines.append(f"Note: {result['note']}") + lines.append("Hours are a planning heuristic. Recalibrate after your first chapter.") + lines.append("Read the chapters free at https://www.deeplearningbook.org/") + return "\n".join(lines) + + +SAMPLE_GOAL = "I want to train convolutional models for image classification and debug them" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Plan a prerequisite-closed reading path through the Deep Learning book.", + epilog="Exit codes: 0 ok · 3 goal outside the book · 4 goal too vague to route.", + ) + parser.add_argument("--goal", help="what you want to be able to do afterwards") + parser.add_argument("--background", default="applied", choices=sorted(BACKGROUNDS), + help="your starting point (default: applied)") + parser.add_argument("--hours-per-week", type=float, default=5.0, + help="study hours available per week (default: 5)") + parser.add_argument("--include-intro", action="store_true", + help="add ch01 to the path; it is context rather than content, " + "so no lane pulls it in on its own (the complete read " + "already includes it)") + parser.add_argument("--output", choices=("text", "json"), default="text") + parser.add_argument("--sample", action="store_true", + help="run against a built-in example goal") + args = parser.parse_args(argv) + + goal = SAMPLE_GOAL if args.sample else args.goal + if not goal: + parser.error("--goal is required (or use --sample)") + if args.hours_per_week <= 0: + parser.error("--hours-per-week must be positive") + + scoped_out = out_of_scope_hits(goal) + lane_scores = score_lanes(goal) + top_score = lane_scores[0][1] if lane_scores else 0 + # An out-of-scope subject wins over a weak lane match: "LoRA fine-tuning" hits the + # practitioner lane on the word "tuning" while being entirely outside the book. + if scoped_out and (len(scoped_out) >= 2 or top_score < 2): + payload = { + "status": "out_of_scope", + "goal": goal, + "reasons": scoped_out, + "pointer": "references/book_to_2026_delta.md", + } + if args.output == "json": + print(json.dumps(payload, indent=2)) + else: + print("OUT OF SCOPE — the 2016 book does not cover this goal.\n") + for reason in scoped_out: + print(f" - {reason}") + print("\nSee references/book_to_2026_delta.md for what replaced the book's " + "position, and route to the skills named above.") + return 3 + + if not lane_scores: + payload = { + "status": "unroutable", + "goal": goal, + "questions": [ + "What do you want to be able to DO afterwards — train, diagnose, " + "derive, or evaluate?", + "Which data type: images, sequences, tabular, or generative modelling?", + "Is this a refresher over known material or a first pass?", + ], + "lanes": {key: lane["label"] for key, lane in LANES.items()}, + } + if args.output == "json": + print(json.dumps(payload, indent=2)) + else: + print("CANNOT ROUTE — the goal does not name a subject in the book.\n") + print("Answer one of these and re-run:") + for question in payload["questions"]: + print(f" - {question}") + print("\nOr name a lane directly:") + for key, label in payload["lanes"].items(): + print(f" {key:<15} {label}") + return 4 + + result = plan(goal, args.background, args.hours_per_week, args.include_intro) + if scoped_out: + result["scope_warnings"] = scoped_out + if args.output == "json": + print(json.dumps(result, indent=2)) + else: + print(render(result)) + if scoped_out: + print("\nPartly outside the book — these parts are not covered:") + for reason in scoped_out: + print(f" - {reason}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/engineering/deep-learning-book/skills/deep-learning-book/scripts/training_diagnostics.py b/engineering/deep-learning-book/skills/deep-learning-book/scripts/training_diagnostics.py new file mode 100644 index 000000000..2664de6f3 --- /dev/null +++ b/engineering/deep-learning-book/skills/deep-learning-book/scripts/training_diagnostics.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""training_diagnostics.py — Chapter 11's triage as an executable decision tree. + +Chapter 11 argues that knowing many algorithms matters less than knowing which one a +measurement calls for, and gives the rule most teams get backwards: *read training +error first*. High training error means the model or the optimizer is the bottleneck, +and collecting more data will not help. + +This tool takes the measurements you already have from a run and returns a ranked +diagnosis, the specific next action, and the chapter that justifies it. Rules fire in +priority order, so a NaN is never diagnosed as overfitting. + +The thresholds are documented defaults, not physics — every one is overridable, and +--output json prints the values actually used. + +Standard library only. No network calls, no model loading. + +Exit codes: + 0 a diagnosis was produced and training looks healthy + 1 a diagnosis was produced and something needs fixing + 2 usage error (argparse) + 4 not enough measurements to diagnose — the missing ones are named +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys + +# Documented defaults. Every one is overridable from the command line. +DEFAULTS = { + # Relative gap = (val - train) / max(train, floor). Above this, overfitting. + "overfit_rel_gap": 0.30, + # Below this relative gap, the run is not overfitting in any actionable sense. + "healthy_rel_gap": 0.10, + # Gradient norm above this is a cliff (ch08); clip before anything else. + "grad_explode": 100.0, + # Gradient norm below this with non-trivial loss means saturation or dead units. + "grad_vanish": 1e-6, + # Divisor floor so a near-zero training loss does not produce an infinite ratio. + "loss_floor": 1e-6, + # Training loss above target * this multiple counts as underfitting. + "underfit_multiple": 1.15, +} + + +class Finding: + def __init__(self, rank: int, code: str, verdict: str, evidence: str, + action: str, chapter: str) -> None: + self.rank = rank + self.code = code + self.verdict = verdict + self.evidence = evidence + self.action = action + self.chapter = chapter + + def as_dict(self) -> dict: + return { + "rank": self.rank, + "code": self.code, + "verdict": self.verdict, + "evidence": self.evidence, + "action": self.action, + "chapter": self.chapter, + } + + +def diagnose(train_loss: float | None, val_loss: float | None, + grad_norm: float | None, target_loss: float | None, + tiny_subset_fits: str, nan_seen: bool, + epochs: int | None, thresholds: dict) -> tuple[list[Finding], dict]: + """Return (findings, metrics). Rules fire in priority order; rank 1 acts first.""" + findings: list[Finding] = [] + metrics: dict = {} + + non_finite = ( + nan_seen + or (train_loss is not None and not math.isfinite(train_loss)) + or (val_loss is not None and not math.isfinite(val_loss)) + ) + + # --- Rule 1: numerics before modelling (ch04) ------------------------------- + if non_finite: + findings.append(Finding( + len(findings) + 1, "NUMERICS", + "Loss is NaN or infinite — this is a numerics failure, not a modelling one", + "a non-finite loss was reported", + "Check in this order: log(0) or log of a negative, division by a near-zero " + "denominator, exp of a large logit, then an exploding gradient. Use fused " + "log_softmax / cross-entropy rather than hand-rolled exp-then-normalize, and " + "work in log-space for probability products.", + "ch04 (Numerical Computation)", + )) + # A non-finite loss makes every downstream ratio meaningless. + return findings, metrics + + # --- Rule 2: is it a bug at all? (ch11) ------------------------------------- + if tiny_subset_fits == "no": + findings.append(Finding( + len(findings) + 1, "BUG", + "The model cannot overfit a tiny subset — this is a bug, not a hard problem", + "--tiny-subset-fits no", + "Stop tuning. Check the data pipeline (labels aligned with inputs?), the loss " + "reduction, whether gradients actually reach the parameters (a detached tensor " + "or a frozen module), and the learning rate. Compare backprop against numerical " + "derivatives on one layer if it is still unclear.", + "ch11 (Practical Methodology — debugging strategies)", + )) + + # --- Rule 3: gradient behaviour (ch08, ch10, ch06) -------------------------- + if grad_norm is not None: + metrics["grad_norm"] = grad_norm + if grad_norm > thresholds["grad_explode"]: + findings.append(Finding( + len(findings) + 1, "EXPLODING_GRADIENT", + "Gradient norm is in cliff territory", + f"grad_norm {grad_norm:g} > {thresholds['grad_explode']:g}", + "Clip gradients by global norm before changing anything else. If a " + "recurrence is in the graph, clipping is not optional. Then re-check the " + "learning rate and the initialization scale.", + "ch08 (Optimization — cliffs), ch10 (exploding gradients through time)", + )) + elif grad_norm < thresholds["grad_vanish"]: + findings.append(Finding( + len(findings) + 1, "VANISHING_GRADIENT", + "Gradient norm is effectively zero — units are saturated or dead", + f"grad_norm {grad_norm:g} < {thresholds['grad_vanish']:g}", + "Check for saturating hidden units (sigmoid/tanh in a deep stack), dead " + "ReLUs from a too-large learning rate, and initialization scale " + "(He/Xavier). Add residual connections or normalization if the stack is " + "deep.", + "ch08 (initialization), ch06 (hidden units), ch10 (vanishing gradients)", + )) + + # --- Rule 4/5: the fit verdict (ch05, ch07, ch11) --------------------------- + if train_loss is not None and val_loss is not None: + denom = max(abs(train_loss), thresholds["loss_floor"]) + gap = val_loss - train_loss + rel_gap = gap / denom + metrics.update({ + "train_loss": train_loss, + "val_loss": val_loss, + "gap": round(gap, 6), + "relative_gap": round(rel_gap, 4), + }) + + underfitting = None + if target_loss is not None: + metrics["target_loss"] = target_loss + underfitting = train_loss > target_loss * thresholds["underfit_multiple"] + + if underfitting: + findings.append(Finding( + len(findings) + 1, "UNDERFIT", + "Training error is above target — the bottleneck is capacity or optimization", + f"train_loss {train_loss:g} > target {target_loss:g} × " + f"{thresholds['underfit_multiple']}", + "Do NOT collect more data — it cannot help while training error is high. " + "In order: tune learning rate and schedule, check initialization, add " + "capacity, remove regularization you added earlier, train longer.", + "ch11 (the data-vs-capacity rule), ch08 (Optimization), ch05 (capacity)", + )) + elif rel_gap > thresholds["overfit_rel_gap"]: + findings.append(Finding( + len(findings) + 1, "OVERFIT", + "Validation error substantially exceeds training error", + f"relative gap {rel_gap:.2f} > {thresholds['overfit_rel_gap']}", + "Work the regularization ladder in cost order, one change at a time: more " + "real data → label-preserving augmentation → early stopping → decoupled " + "weight decay (AdamW, not Adam+L2) → label smoothing → dropout. Shrink the " + "model LAST: past the interpolation threshold, double descent means a " + "bigger model with more data can generalize better.", + "ch07 (Regularization), ch05 (capacity and the U-curve's caveat)", + )) + elif rel_gap < thresholds["healthy_rel_gap"] and target_loss is None: + findings.append(Finding( + len(findings) + 1, "GAP_SMALL_TARGET_UNKNOWN", + "The train/val gap is small — but without a target loss this cannot " + "distinguish 'converged' from 'underfitting equally on both splits'", + f"relative gap {rel_gap:.2f} < {thresholds['healthy_rel_gap']}, " + "no --target-loss supplied", + "Supply --target-loss (a human baseline, a published number, or the " + "irreducible-error estimate for the task) and re-run. Chapter 11's first " + "step is naming the metric and its target value, and this is why.", + "ch11 (determine goals: error metric and target value)", + )) + + if not findings: + findings.append(Finding( + 1, "HEALTHY", + "No rule fired — the measurements supplied look healthy", + "; ".join(f"{k}={v}" for k, v in metrics.items()) or "measurements within thresholds", + "Confirm the metric you are optimizing is the one you care about, then change " + "one thing per experiment and keep the log.", + "ch11 (Practical Methodology)", + )) + + if epochs is not None: + metrics["epochs"] = epochs + return findings, metrics + + +def render(findings: list[Finding], metrics: dict, thresholds: dict) -> str: + lines = ["TRAINING DIAGNOSIS", "=" * 68] + if metrics: + lines.append("Measurements: " + " ".join(f"{k}={v}" for k, v in metrics.items())) + lines.append("") + for finding in findings: + lines.append(f"[{finding.rank}] {finding.code} — {finding.verdict}") + lines.append(f" evidence : {finding.evidence}") + lines.append(f" action : {finding.action}") + lines.append(f" chapter : {finding.chapter}") + lines.append("") + lines.append("Thresholds used: " + ", ".join(f"{k}={v}" for k, v in thresholds.items())) + lines.append("Rules fire in priority order — act on [1] before anything below it.") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Diagnose a training run against Chapter 11's decision tree.", + epilog="Exit codes: 0 healthy · 1 something needs fixing · 4 not enough input.", + ) + parser.add_argument("--train-loss", type=float, help="final or current training loss") + parser.add_argument("--val-loss", type=float, help="matching validation loss") + parser.add_argument("--target-loss", type=float, + help="the loss value that would count as success — a human " + "baseline, a published number, or an irreducible-error " + "estimate. Without it, underfitting cannot be detected.") + parser.add_argument("--grad-norm", type=float, help="observed global gradient norm") + parser.add_argument("--epochs", type=int, help="epochs trained so far (recorded only)") + parser.add_argument("--nan", action="store_true", + help="the loss went NaN or inf at any point") + parser.add_argument("--tiny-subset-fits", choices=("yes", "no", "unknown"), + default="unknown", + help="can the model drive training loss to ~0 on 10-50 examples? " + "(ch11's smoke test; default: unknown)") + parser.add_argument("--overfit-rel-gap", type=float, default=DEFAULTS["overfit_rel_gap"]) + parser.add_argument("--healthy-rel-gap", type=float, default=DEFAULTS["healthy_rel_gap"]) + parser.add_argument("--grad-explode", type=float, default=DEFAULTS["grad_explode"]) + parser.add_argument("--grad-vanish", type=float, default=DEFAULTS["grad_vanish"]) + parser.add_argument("--underfit-multiple", type=float, + default=DEFAULTS["underfit_multiple"]) + parser.add_argument("--output", choices=("text", "json"), default="text") + parser.add_argument("--sample", action="store_true", + help="run against a built-in overfitting example") + args = parser.parse_args(argv) + + if args.sample: + args.train_loss, args.val_loss = 0.02, 1.90 + args.grad_norm, args.epochs = 0.4, 30 + args.tiny_subset_fits = "yes" + + thresholds = { + "overfit_rel_gap": args.overfit_rel_gap, + "healthy_rel_gap": args.healthy_rel_gap, + "grad_explode": args.grad_explode, + "grad_vanish": args.grad_vanish, + "loss_floor": DEFAULTS["loss_floor"], + "underfit_multiple": args.underfit_multiple, + } + + have_losses = args.train_loss is not None and args.val_loss is not None + if not have_losses and not args.nan and args.grad_norm is None \ + and args.tiny_subset_fits == "unknown": + missing = { + "status": "insufficient_input", + "need_at_least_one_of": [ + "--train-loss with --val-loss", + "--grad-norm", + "--nan", + "--tiny-subset-fits yes|no", + ], + "note": "Chapter 11's first instruction is to instrument the run. This tool " + "reads instruments; it does not guess.", + } + if args.output == "json": + print(json.dumps(missing, indent=2)) + else: + print("NOT ENOUGH INPUT — supply at least one of:") + for item in missing["need_at_least_one_of"]: + print(f" {item}") + print(f"\n{missing['note']}") + return 4 + + findings, metrics = diagnose( + args.train_loss, args.val_loss, args.grad_norm, args.target_loss, + args.tiny_subset_fits, args.nan, args.epochs, thresholds, + ) + + if args.output == "json": + print(json.dumps({ + "findings": [f.as_dict() for f in findings], + "metrics": metrics, + "thresholds": thresholds, + }, indent=2)) + else: + print(render(findings, metrics, thresholds)) + + return 0 if findings[0].code == "HEALTHY" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/.claude-plugin/authoring-notes.json b/marketing/linkedin/.claude-plugin/authoring-notes.json new file mode 100644 index 000000000..90a87432b --- /dev/null +++ b/marketing/linkedin/.claude-plugin/authoring-notes.json @@ -0,0 +1,11 @@ +{ + "source": { + "spec": "https://github.com/alirezarezvani/claude-skills/discussions/934", + "build_pattern": "Domain plugin — context: fork orchestrator with a policy gate in front of a deterministic five-lane router; every sub-skill ships 3 stdlib-only tools with typed exit codes, references citing 7 sources each with per-claim confidence levels (LinkedIn-official / third-party study / folklore), and fillable assets. Answers discussion #934's ten requested capabilities: content strategy, post ideation, multi-format drafting, posting cadence, newsletter planning, profile optimization, outreach messages, reach tactics via comments and communities, repurposing from articles/video/audio, and analysis of existing posts.", + "distinct_from": "marketing-skill/x-twitter-growth (X/Twitter mechanics — different platform, format economics, and rules); marketing-skill/social-content and social-media-manager (multi-platform brand calendars — this is one person's own presence on one platform, in depth); marketing-skill/social-media-analyzer (cross-platform campaign reporting — this reads your own LinkedIn export and refuses to conclude below 10 posts); marketing-skill/cold-email (different channel, different law, different caps); productivity/andreessen (venture judgement, not audience building)" + }, + "attribution": { + "policy_basis": "Refusal rules are derived from LinkedIn's published User Agreement §8.2, the Prohibited Software and Extensions help article, and the Professional Community Policies. No LinkedIn code, data, or proprietary material is included, and the plugin makes no LinkedIn API calls.", + "evidence_discipline": "Two widely repeated third-party claims are corrected rather than propagated. (1) The claim that a personalised connection note roughly triples acceptance (~45% vs ~15%) is not supported by the largest available samples, which show acceptance close to identical either way (~26.4%); what the note moves is the post-accept reply rate (~5.4% to ~9.4%). (2) The in-body external-link reach reduction (~19% median in a ~1.3M-post third-party study) has never been confirmed by LinkedIn as a penalty and has a plausible non-punitive explanation via dwell time, so it is flagged as a warning rather than a blocking finding. Per-claim confidence levels are carried in every reference." + } +} diff --git a/marketing/linkedin/.claude-plugin/plugin.json b/marketing/linkedin/.claude-plugin/plugin.json new file mode 100644 index 000000000..587809e21 --- /dev/null +++ b/marketing/linkedin/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "name": "linkedin", + "description": "Organic LinkedIn presence, end to end, with the platform rules enforced in code. An orchestrator (context: fork) gates every request against LinkedIn's User Agreement §8.2 — refusing automation, scraping, engagement pods, bulk DMs, fake identity, fabricated proof, and named third-party automation tools, each with a compliant substitute — then routes to one of five sub-skills. linkedin-profile scores a headline on five dimensions against the 220-character cap, audits the whole profile 0-100 with fixes ranked by points per hour, and assembles an About section that survives the '…see more' fold. linkedin-strategy validates a positioning brief (six real objectives, an audience specific enough to exclude someone, 2-4 proof-backed pillars, a mandatory exclusion list), sizes a week against the hours that actually exist and refuses below a 90-minute floor, and gates a newsletter on eligibility plus a six-month cadence commitment. linkedin-content lints posts 0-100 across mechanics, hook, integrity, and accessibility — blocking on the 3,000-character cap, engagement bait, and Unicode pseudo-bold that screen readers cannot read — picks the format the material actually supports, and splits long sources into standalone units against a reuse ledger. linkedin-engagement builds a capped commenting roster, refuses template outreach without a person-specific line, and refuses volumes no human could type. linkedin-analytics reads your own export, reports medians rather than misleading means, and tests candidate patterns against a seeded permutation null with multiple-comparisons accounting — refusing to conclude anything below 10 posts. 17 stdlib-only Python tools, 15 references citing 7 sources each with per-claim confidence levels. No LinkedIn credentials, no API calls, nothing auto-sent.", + "version": "2.12.0", + "author": { + "name": "Alireza Rezvani", + "url": "https://alirezarezvani.com" + }, + "homepage": "https://github.com/alirezarezvani/claude-skills/tree/main/marketing/linkedin", + "repository": "https://github.com/alirezarezvani/claude-skills", + "license": "MIT", + "skills": [ + "./skills/linkedin-skills", + "./skills/linkedin-profile", + "./skills/linkedin-strategy", + "./skills/linkedin-content", + "./skills/linkedin-engagement", + "./skills/linkedin-analytics" + ] +} diff --git a/marketing/linkedin/README.md b/marketing/linkedin/README.md new file mode 100644 index 000000000..1e4aeaee5 --- /dev/null +++ b/marketing/linkedin/README.md @@ -0,0 +1,108 @@ +# linkedin — organic LinkedIn presence, with the rules enforced in code + +A strategic assistant for building an authentic LinkedIn presence over months, not a post +generator. Built in response to +[discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934). + +**The design constraint that shapes everything else:** this plugin holds no LinkedIn +credentials, makes no API calls, scrapes nothing, and sends nothing. Every output is text the +account holder posts themselves. Automated posting, connecting, commenting, and liking are +prohibited by LinkedIn's User Agreement §8.2, and a restricted account ends a compounding +asset that took months to build. So the refusals are in code, at the front of every lane, +and each one names a compliant substitute. + +``` +/cs:linkedin # gate + route +/cs:grill-linkedin # five forcing questions before the work starts +/cs:linkedin-profile # headline, About, whole-profile audit +/cs:linkedin-plan # brief, pillars, cadence, newsletter +/cs:linkedin-post # format, draft, lint +/cs:linkedin-repurpose # article/talk -> posts, with a reuse ledger +/cs:linkedin-outreach # comment roster + one message at a time +/cs:linkedin-analyze # your own export, analysed honestly +``` + +--- + +## What is here + +| Skill | Tools | Does | +|---|---|---| +| **linkedin-skills** (orchestrator, `context: fork`) | 2 | Policy gate (ALLOW / CONSTRAIN / REFUSE) then a deterministic five-lane router with cross-lane prerequisites | +| **linkedin-profile** | 3 | Headline scored on five dimensions vs the 220-char cap; whole profile 0-100 with fixes ranked by points per hour; About section assembled to survive the fold | +| **linkedin-strategy** | 3 | Positioning brief validator; cadence priced in minutes with a 90-minute floor; newsletter eligibility + six-month sustainability gate | +| **linkedin-content** | 3 | Post linter (mechanics / hook / integrity / accessibility); format picker; repurpose splitter with a reuse ledger | +| **linkedin-engagement** | 3 | Capped commenting roster; message builder that refuses templates; volume guard that refuses automation-shaped plans | +| **linkedin-analytics** | 3 | Median/MAD describer; four-gate permutation pattern miner; experiment planner | + +Plus 2 agents, 8 commands, 15 references (7 sources each), 11 assets. + +## The parts that are different + +**The policy gate runs before anything is drafted.** Seven refusal rules covering automation, +scraping, engagement pods, bulk messaging, fake identity, fabricated proof, and named +third-party automation tools — each with the User Agreement clause and a substitute that +achieves the same goal legitimately. A REFUSE outranks any route. + +**Evidence is graded, and two popular claims are corrected.** Every quantitative claim carries +🟢 (LinkedIn-official), 🟡 (third-party study), or 🔴 (folklore, named as folklore). + +- The "a personalised note triples acceptance" claim is not supported by the largest samples, + which show acceptance close to identical either way (~26.4%). What a note actually moves is + the **post-accept reply rate** (~5.4% → ~9.4%). That changes what the note should say: it + earns the conversation, not the meeting — which is why `outreach_message_builder.py` + refuses an ask in a first-touch connection note. +- The ~19% in-body link reach reduction has **never been confirmed by LinkedIn as a penalty** + and has a plausible non-punitive explanation via dwell time. It is a warning, not a block. + +**The analytics refuse to over-conclude.** `pattern_miner.py` puts every candidate through four +gates: a group-size floor, a 15% minimum relative effect, a seeded permutation test, and a +multiple-comparisons accounting that reports how many candidates would pass on noise alone. +Below 10 posts it refuses to test anything. `NOTHING_SURVIVED` is the most common honest +answer and is reported as a finding, not a failure. + +**Accessibility is a blocking finding, not a footnote.** Unicode pseudo-bold — the output of +"bold text generators" — is a blocking lint failure, because screen readers announce those +characters as mathematical symbols and LinkedIn search does not index them as words. + +**Refusals are refusals.** A cadence under 90 minutes a week returns a comment-only plan +rather than a schedule that will be abandoned in week five. A newsletter whose six-month cost +exceeds the budget is refused before the promise is made. An experiment that needs more posts +than a quarter allows is reported as infeasible rather than quietly re-sized. + +## Quick start + +```bash +# See every tool run with zero configuration +python3 skills/linkedin-skills/scripts/linkedin_policy_gate.py --sample --output human +python3 skills/linkedin-profile/scripts/headline_scorer.py --sample-weak --output human +python3 skills/linkedin-strategy/scripts/cadence_planner.py --sample --output human +python3 skills/linkedin-content/scripts/post_linter.py --sample --output human +python3 skills/linkedin-engagement/scripts/outreach_volume_guard.py --sample --output human +python3 skills/linkedin-analytics/scripts/pattern_miner.py --sample --output human +``` + +Every tool supports `--help`, `--sample`, and `--output json`, uses the standard library +only, and returns typed exit codes so an agent can branch on the verdict rather than parse +prose. + +## Distinct from + +- **`marketing-skill/x-twitter-growth`** — X/Twitter mechanics. Different platform, different + format economics, different rules. +- **`marketing-skill/social-content`, `social-media-manager`** — multi-platform brand + calendars. This is one person's own presence on one platform, in depth. +- **`marketing-skill/social-media-analyzer`** — cross-platform campaign reporting. This reads + your own LinkedIn export and refuses to conclude below 10 posts. +- **`marketing-skill/cold-email`** — different channel, different law, different caps. + +## Provenance + +Requested in [discussion #934](https://github.com/alirezarezvani/claude-skills/discussions/934). +Refusal rules derive from LinkedIn's published User Agreement §8.2, the Prohibited Software +and Extensions help article, and the Professional Community Policies. No LinkedIn code, data, +or proprietary material is included. See `.claude-plugin/authoring-notes.json`. + +--- + +**Version:** 1.0.0 · MIT · 6 skills · 17 stdlib tools · 15 references · nothing auto-sent diff --git a/marketing/linkedin/agents/cs-linkedin-editor.md b/marketing/linkedin/agents/cs-linkedin-editor.md new file mode 100644 index 000000000..362f13424 --- /dev/null +++ b/marketing/linkedin/agents/cs-linkedin-editor.md @@ -0,0 +1,89 @@ +--- +name: cs-linkedin-editor +description: Drafts, edits, and lints LinkedIn posts to a publishable standard — hook that survives the ~140-character mobile fold, one idea, real numbers, no engagement bait, no Unicode pseudo-bold, alt text and captions written. Runs post_linter.py to a clean exit rather than declaring a draft done, picks the format the material actually supports, and splits long sources into standalone units against a reuse ledger so nothing goes out twice. Refuses to invent a metric, client, result, or quote. Use when someone wants a LinkedIn post written, reviewed, rewritten, or repurposed from an article, talk, or transcript. +skills: marketing/linkedin/skills/linkedin-content +domain: marketing +model: opus +tools: [Read, Bash, Write, Edit] +--- + +# LinkedIn Editor Agent + +## Purpose + +`cs-linkedin-editor` owns the draft. Its job is to get the specific thing out of the author's +head and onto the page, then remove everything that would stop a stranger reading it. + +1. **Interview for specifics first.** Never draft from a topic. Get the number, the mistake, + the sentence someone actually said, the thing that surprised them. A post with no + specifics cannot be fixed by editing. +2. **Pick the format from the material** (`format_picker.py`), not from what is working for + other people this month. +3. **Draft to the fold.** A sentence completes inside the first ~140 characters. That is + where the reader decides. +4. **Lint to a clean exit** (`post_linter.py`). Two passes is normal. Do not declare a draft + finished on a REVISE unless the user explicitly accepts a named warning. +5. **Hand back with the accessibility work done** — alt text written, captions flagged as + required, no pseudo-bold anywhere. + +## Voice + +- Cuts first, adds second. The first paragraph is usually throat-clearing; check whether the + post starts better at paragraph two, because it usually does. +- Reads drafts out loud. Every sentence you stumble on is one a reader stumbles on. +- Allergic to the interchangeable sentence. If a line would fit any post about anything, it + is not carrying meaning. +- Never flatters a draft. "This is close, and here are the three things stopping it" is + more useful than encouragement. + +## Hard rules + +1. **Never fabricate a number, client, result, credential, or quote.** If the proof does not + exist, the post is about the process — which is a legitimate post and it ages better. +2. **No engagement bait.** "Comment X below", "like if you agree", "tag someone", "Agree?". + Blocking in the linter, named as demoted content in LinkedIn's Professional Community + Policies, and recognisable to every reader. +3. **No Unicode pseudo-bold.** Screen readers announce it as mathematical symbols and search + does not index it as words. This is an accessibility failure, not a style preference. +4. **Alt text on every image, captions on every video.** LinkedIn does not add them for you, + and auto-captions mangle exactly the domain terms the post is about. +5. **Links in the first comment**, with "link in the comments" in the post — unless the click + is the goal and the user accepts the reach cost, which they should say out loud. +6. **One idea per post.** If it needs two, it is two posts. +7. **The author reads every line before it ships.** Say so on handover. + +## Skill Integration + +**Skill location:** `../skills/linkedin-content/` + +### Tools + +1. `skills/linkedin-content/scripts/post_linter.py` — 0-100 across mechanics, hook, + integrity, and accessibility; blocking findings for the 3,000-char cap, engagement bait, + and pseudo-bold. +2. `skills/linkedin-content/scripts/format_picker.py` — nine native formats scored against + goal, material, and minutes; refuses camera-less video and decision-less polls. +3. `skills/linkedin-content/scripts/repurpose_splitter.py` — standalone units with a + content-hash reuse ledger so the same idea does not go out twice across months. + +### Knowledge bases + +- `skills/linkedin-content/references/hook_and_fold_mechanics.md` (7 sources) +- `skills/linkedin-content/references/post_formats_canon.md` (7 sources) +- `skills/linkedin-content/references/repurposing_discipline.md` (7 sources) +- `skills/linkedin-content/references/accessibility_and_inclusion.md` (7 sources) + +## Differentiates from siblings + +- **vs `cs-copywriting` / `content-humanizer`** — general copy craft and de-AI passes. Reach + for those on tone; this one owns LinkedIn's mechanics, its fold, and its accessibility floor. +- **vs `cs-linkedin-orchestrator`** — that one routes and gates. This one writes. + +## Related agents + +- [cs-linkedin-orchestrator](cs-linkedin-orchestrator.md) — routing, policy gate, and the + other four lanes + +--- + +**Version:** 1.0.0 diff --git a/marketing/linkedin/agents/cs-linkedin-orchestrator.md b/marketing/linkedin/agents/cs-linkedin-orchestrator.md new file mode 100644 index 000000000..ac4f2bba3 --- /dev/null +++ b/marketing/linkedin/agents/cs-linkedin-orchestrator.md @@ -0,0 +1,100 @@ +--- +name: cs-linkedin-orchestrator +description: Routes any LinkedIn organic-growth request to the right lane and gates it against LinkedIn's User Agreement before a word is drafted. Runs the policy gate (ALLOW / CONSTRAIN / REFUSE against §8.2 — automation, scraping, engagement pods, bulk DMs, fake identity, fabricated proof, prohibited third-party tools), then the deterministic five-lane router (profile / strategy / content / engagement / analytics), then walks the forcing questions until the chosen lane can run honestly. Holds no LinkedIn credentials and sends nothing. Use when someone wants to grow an organic LinkedIn presence, fix a profile, plan content, write posts, do outreach, or understand their numbers. +skills: marketing/linkedin/skills/linkedin-skills +domain: marketing +model: opus +tools: [Read, Bash, Write, Edit] +--- + +# LinkedIn Orchestrator Agent + +## Purpose + +`cs-linkedin-orchestrator` runs the `linkedin` domain end to end for one person's own +presence. It is a router with a gate in front of it, and the gate comes first. + +1. **Gate.** `linkedin_policy_gate.py` on the user's own words, before any drafting. Exit 4 + REFUSE means a named rule is broken — do not draft it, name the rule, and offer the + substitute the gate prints. Exit 3 CONSTRAIN means proceed and say the constraint out + loud. Exit 0 ALLOW means proceed. +2. **Route.** `linkedin_goal_router.py` scores five lanes. Route at exit 0, ask exactly one + clarifying question at exit 2 (naming both candidates with a recommendation), and at + exit 3 ask what they want to walk away with rather than guessing. +3. **Grill.** Walk the five forcing questions one at a time, each with a recommended answer. + Stop as soon as the lane can run honestly — do not run the full set for its own sake. +4. **Run the lane.** Invoke the sub-skill, use its tools, iterate against their exit codes. +5. **Deliver.** The artifact, the confidence level on any platform claim, and the one thing + the user has to do next that no tool can do for them. + +## Voice + +- Blunt about the trade. The shortcuts work faster and risk the account. Say the arithmetic + once, then respect the answer. +- Evidence-graded by default. 🟢 LinkedIn-official, 🟡 third-party study, 🔴 folklore — and + folklore gets named as folklore rather than repeated. +- Refuses without moralising. One sentence, the rule, the substitute, then move on. +- Never impressed by follower counts, including the user's. + +## Hard rules + +1. **Gate before route, route before draft.** A REFUSE outranks any route. +2. **Nothing is sent and nothing is fetched.** No credentials, no API calls, no scraping. + Every output is text the user posts themselves. +3. **Never fabricate a number, client, result, credential, or quote** — not even as a + placeholder that "they'll fill in later". Placeholders ship. +4. **The account holder is the author of record.** Say it when handing over a draft. +5. **Never silently chain lanes.** Offer the next lane as a question with a recommendation. +6. **Refuse pods, automation tools, scraping, and bulk messaging** every time, including + when the user pushes back. If they reaffirm after the explanation, say the risk is theirs + and decline to build it — do not build a partial version. +7. **Under 10 posts, describe; do not conclude.** Applies to every claim about their data. + +## Skill Integration + +**Skill location:** `../skills/linkedin-skills/` + +### Orchestrator tools + +1. `skills/linkedin-skills/scripts/linkedin_policy_gate.py` — 7 refusal rules + 3 constraint + rules against User Agreement §8.2 and the Professional Community Policies, each with a + named compliant substitute. +2. `skills/linkedin-skills/scripts/linkedin_goal_router.py` — deterministic five-lane + classifier with cross-lane prerequisites (content needs a brief; analytics needs volume; + engagement wastes a weak profile). + +### Lanes + +- `linkedin-profile` — headline scorer, whole-profile auditor ranked by points per hour, + About builder that survives the fold. +- `linkedin-strategy` — positioning brief validator, cadence planner with a 90-minute floor, + newsletter eligibility and six-month sustainability gate. +- `linkedin-content` — post linter (mechanics / hook / integrity / accessibility), format + picker, repurpose splitter with a reuse ledger. +- `linkedin-engagement` — comment roster with per-account caps, message builder that refuses + templates, volume guard that refuses automation-shaped plans. +- `linkedin-analytics` — median/MAD describer, four-gate permutation pattern miner, + experiment planner. + +### Knowledge bases + +- `skills/linkedin-skills/references/linkedin_platform_canon.md` — LiRank, dwell time, the + link-penalty evidence, and what LinkedIn does not publish (7 sources) +- `skills/linkedin-skills/references/policy_and_account_safety.md` — §8.2, prohibited + software, restriction triggers, regional obligations (7 sources) + +## Differentiates from siblings + +- **vs `cs-social-media-manager` / `social-content`** — those plan multi-platform brand + social. This is one person's own LinkedIn presence, in depth, with platform rules attached. +- **vs `x-twitter-growth`** — different platform, different format economics, different rules. +- **vs `cs-cold-email`** — email outreach is a different channel with different law and + different caps. + +## Related agents + +- [cs-linkedin-editor](cs-linkedin-editor.md) — the drafting and linting counterpart + +--- + +**Version:** 1.0.0 diff --git a/marketing/linkedin/commands/cs-grill-linkedin.md b/marketing/linkedin/commands/cs-grill-linkedin.md new file mode 100644 index 000000000..55d49e4f9 --- /dev/null +++ b/marketing/linkedin/commands/cs-grill-linkedin.md @@ -0,0 +1,73 @@ +--- +name: "cs-grill-linkedin" +description: "/cs:grill-linkedin — Interrogate a LinkedIn plan one forcing question at a time, each with a recommended answer anchored in the plugin's canon. Refuses to start the work until the objective, the audience, the hours, the proof, and the exclusion list survive the questions." +argument-hint: "[the LinkedIn plan or ambition you want pressure-tested]" +--- + +# /cs:grill-linkedin — One question at a time, with a recommendation + +**Command:** `/cs:grill-linkedin [your plan]` + +Most LinkedIn plans fail on inputs, not execution. This walks the five questions that decide +whether any of the tools can run honestly. One question per turn, each with a recommended +answer and the canon it comes from. Never bundle. + +## When to run + +- The ambition is real but the plan is vague ("I want to build a presence") +- Before committing a quarter to a cadence +- When a previous attempt stalled and nobody has said why + +## The questions + +**Q1 — What has to be true in 90 days for this to have been worth it?** +*Recommended: one observable outcome another person could verify — an inbound conversation, +an offer, a hire. Not a follower count.* +Canon: [`objective_to_pillars.md`](../skills/linkedin-strategy/references/objective_to_pillars.md). Follower count moves for +reasons unrelated to the objective; optimising the number you can see instead of the outcome +you want is the most common way a LinkedIn strategy fails while appearing to work. + +**Q2 — Who is this for, specifically enough that someone is excluded?** +*Recommended: role + company stage + the problem they have this quarter.* +Canon: same document, and `positioning_brief.py` refuses "business leaders" at exit 3. An +audience that excludes nobody cannot guide a single editorial decision. + +**Q3 — How many minutes a week will you protect, measured from a bad week?** +*Recommended: the honest number, not the aspirational one. Below 90, the answer is a +comment-only week.* +Canon: [`cadence_and_consistency.md`](../skills/linkedin-strategy/references/cadence_and_consistency.md). A cadence abandoned in +week five is worse than one never started, because the abandonment is visible. + +**Q4 — What proof already exists?** +*Recommended: name shipped work, a measurement, a repo, a hire, a talk. If none exists, the +first pillar is process, not results.* +Canon: [`policy_and_account_safety.md`](../skills/linkedin-skills/references/policy_and_account_safety.md) — the fabrication refusal. +A pillar with no proof is a claim you would have to invent evidence for. + +**Q5 — What will you not post about?** +*Recommended: two topics, including the trending one you have no edge on.* +Canon: `objective_to_pillars.md`. A positioning that excludes nothing is availability, and +the exclusion list is what settles the "should I comment on this news cycle" question in +advance. + +## Discipline + +- **One question per turn.** Wait for the answer. Never bundle. +- **Always recommend.** A question with no recommended answer is homework, not a grill. +- **Cite the canon** for each challenge — the reference document, not a feeling. +- **Stop early** when the lane can run honestly. The full set is not a ritual. +- **Push back once on a weak answer, then accept it.** Their presence, their call. Record the + weak answer in the brief so it is visible later rather than arguing it now. + +## Stop conditions + +- All five answered well enough that `positioning_brief.py` would exit 0 → hand off to + [`/cs:linkedin-plan`](cs-linkedin-plan.md). +- The user declines to answer Q1 or Q2 → say plainly that the work cannot be aimed without + them, and offer the profile lane instead, which needs neither. + +## Related + +- Agent: [`cs-linkedin-orchestrator`](../agents/cs-linkedin-orchestrator.md) +- Command: [`/cs:linkedin`](cs-linkedin.md) +- Agreement: [`linkedin_operating_agreement.md`](../skills/linkedin-skills/assets/linkedin_operating_agreement.md) diff --git a/marketing/linkedin/commands/cs-linkedin-analyze.md b/marketing/linkedin/commands/cs-linkedin-analyze.md new file mode 100644 index 000000000..2c47630aa --- /dev/null +++ b/marketing/linkedin/commands/cs-linkedin-analyze.md @@ -0,0 +1,72 @@ +--- +name: "cs-linkedin-analyze" +description: "/cs:linkedin-analyze — Read your own exported LinkedIn post data, report medians and outlier bands rather than misleading means, test candidate patterns against a seeded permutation null with multiple-comparisons accounting, and size a real experiment. Refuses to conclude anything below 10 posts." +argument-hint: "[path to your LinkedIn post export, or the pattern you think you see]" +--- + +# /cs:linkedin-analyze — Describe honestly, refuse to over-conclude + +**Command:** `/cs:linkedin-analyze [export path or the claim to test]` + +Your own data only. Export from LinkedIn Analytics → Post impressions → Export, or Settings +→ Data privacy → Get a copy of your data. Nothing is fetched; scraping post data is +prohibited by User Agreement §8.2 and none of this needs it. + +## When to run + +- "Why did my reach drop?" +- "Do carousels actually do better for me?" +- "What's working?" +- Before changing strategy on the basis of one post that did well + +## What you get + +1. **A description** — median and MAD, percentile bands, a 1.5×IQR breakout threshold, and a + per-post band from BREAKOUT to DUD. +2. **A verdict on the pattern** — SUPPORTED, NOT_SUPPORTED, TOO_SMALL, or NOT_TESTED, with + the reason for each, plus how many candidates would pass on noise alone. +3. **A sized experiment** if something survived — or an honest "this needs more posts than a + quarter allows". + +## Workflow + +```bash +python3 ../skills/linkedin-analytics/scripts/post_performance_analyzer.py \ + --input export.csv --csv --output human +# exit 2 = under 10 posts. Descriptive only. Say so and stop. + +python3 ../skills/linkedin-analytics/scripts/pattern_miner.py \ + --input export.csv --csv --output human +# exit 2 = nothing survived. This is a real finding, not a failure. + +# CV for the planner = 1.4826 * MAD / median, from step one +python3 ../skills/linkedin-analytics/scripts/experiment_planner.py \ + --hypothesis "..." --variable "..." --cv 0.45 --effect 0.30 \ + --posts-per-week 2 --max-weeks 12 --output human +``` + +## Discipline + +- **Under 10 posts, describe; do not conclude.** State it plainly rather than hedging into + something that reads like a conclusion. +- **"Nothing survived" is the most common honest answer.** Report it as a finding. +- **A pattern in past posts is a hypothesis.** Retrospective data is confounded — you made + carousels when you had structured material, on topics you knew best, in weeks you had time. +- **Never benchmark against someone else's numbers.** Different denominator, different + audience, usually a vendor's sample. +- **Follower count is not a success metric.** Point them at the Tier 1 log instead. +- **One good post is not evidence.** It is the least informative event available. + +## Stop conditions + +- Description delivered and the user knows which three outcome metrics to log by hand → done. +- Miner returns nothing supported → say so, recommend re-running in six weeks, and stop. + Do not keep slicing the data until something passes. +- Experiment planner says TOO_LONG → present the minimum detectable effect in their window + and let them decide. Do not quietly shrink the effect to make it fit. + +## Related + +- Skill: [`linkedin-analytics`](../skills/linkedin-analytics/SKILL.md) +- Log: [`measurement_log_template.md`](../skills/linkedin-analytics/assets/measurement_log_template.md) +- Reference: [`evidence_thresholds.md`](../skills/linkedin-analytics/references/evidence_thresholds.md) diff --git a/marketing/linkedin/commands/cs-linkedin-outreach.md b/marketing/linkedin/commands/cs-linkedin-outreach.md new file mode 100644 index 000000000..45757ad10 --- /dev/null +++ b/marketing/linkedin/commands/cs-linkedin-outreach.md @@ -0,0 +1,69 @@ +--- +name: "cs-linkedin-outreach" +description: "/cs:linkedin-outreach — Build a weekly commenting roster inside a real time budget, and write one outreach message at a time that refuses to be a template. Caps volume against LinkedIn's observed invitation limits and refuses automation-shaped plans. Nothing is ever sent." +argument-hint: "[who you want to reach, and how much time you have]" +--- + +# /cs:linkedin-outreach — Comments first, messages second + +**Command:** `/cs:linkedin-outreach [who, and how much time]` + +**Nothing here is sent.** No credentials, no API calls. Automated connecting, messaging, +commenting, liking, and sharing are prohibited by LinkedIn's User Agreement §8.2. + +## When to run + +- "Who should I be commenting on?" +- "Write a connection request to X" +- "How many invites can I safely send this week?" +- Reach is flat and you are only publishing + +## What you get + +1. **A five-day comment roster** from accounts you name, capped at two appearances per + account per week and balanced across tiers. +2. **A volume verdict** — safe, tight, over a cap, or refused as an automation plan. +3. **One assembled message** with the person-specific line enforced and the 200/300-character + cap checked. +4. **The follow-up rule**, stated once. + +## Workflow + +```bash +# 1. Roster (tiers: huge | larger | peer | smaller) +python3 ../skills/linkedin-engagement/scripts/comment_target_planner.py \ + --account "Priya Raman:5:4:larger" --account "Tomas Lind:5:3:peer" \ + --minutes-per-day 18 --output human + +# 2. Volume check BEFORE writing anything +python3 ../skills/linkedin-engagement/scripts/outreach_volume_guard.py \ + --invites 20 --pending 5 --minutes 120 --acceptance 0.42 --output human +# exit 4 = refused as an automation plan. Do not build a smaller version of it silently. + +# 3. One message, for one person +python3 ../skills/linkedin-engagement/scripts/outreach_message_builder.py \ + --type connection --recipient "Priya" --specific-line "..." --reason "..." --output human +``` + +## Discipline + +- **No engagement pods.** Coordinated reciprocal commenting is inauthentic engagement under + §8.2 regardless of who pressed the key. +- **The tool builds the roster, never the comments.** A generated comment is exactly what the + rule names, and it is recognisable anyway. +- **Every message carries a line that could only have been written for that person.** +- **No ask in a first-touch connection note.** The note earns the conversation, not the meeting. +- **Acceptance below 20% is a stop signal**, not a reason to send more. +- **One follow-up, a week later, only with something new to say.** + +## Stop conditions + +- Roster built and the first day's comments written by the user → done. +- Volume guard at exit 4 → refuse, explain once, offer the manual cadence. If the user + reaffirms, say the risk is theirs and decline to build it. +- Message at exit 0 → hand it over with "send this yourself, to this one person". + +## Related + +- Skill: [`linkedin-engagement`](../skills/linkedin-engagement/SKILL.md) +- Worksheet: [`outreach_worksheet.md`](../skills/linkedin-engagement/assets/outreach_worksheet.md) diff --git a/marketing/linkedin/commands/cs-linkedin-plan.md b/marketing/linkedin/commands/cs-linkedin-plan.md new file mode 100644 index 000000000..878a3f94c --- /dev/null +++ b/marketing/linkedin/commands/cs-linkedin-plan.md @@ -0,0 +1,63 @@ +--- +name: "cs-linkedin-plan" +description: "/cs:linkedin-plan — Build the positioning brief (objective, audience, 2-4 proof-backed pillars, exclusion list), size a weekly cadence against the hours that actually exist, and gate a newsletter against eligibility and a six-month commitment." +argument-hint: "[what you want LinkedIn to do for you in 90 days]" +--- + +# /cs:linkedin-plan — Brief, cadence, newsletter + +**Command:** `/cs:linkedin-plan [your 90-day goal]` + +Three decisions in order. Out of order is why most LinkedIn efforts produce a feed of +unrelated observations and stop in week five. + +## When to run + +- "What should I post about?" / "How often should I post?" +- "Should I start a LinkedIn newsletter?" +- Before drafting anything — posts without pillars are noise + +## What you get + +1. **A validated positioning brief** — one of six real objectives, an audience specific + enough to exclude someone, two to four pillars with shares summing to 100, and at least + two exclusions. +2. **Observable 90-day criteria.** Follower count is deliberately not among them. +3. **A weekly plan priced in minutes**, allocated by stage, plus the minimum viable week that + survives a bad week. +4. **A newsletter verdict** — green, thin, or refused, with a 12-issue arc and a stop rule. + +## Workflow + +```bash +python3 ../skills/linkedin-strategy/scripts/positioning_brief.py --input brief.json --output human +# exit 3 = objective or audience too vague to proceed. Fix that before anything else. + +python3 ../skills/linkedin-strategy/scripts/cadence_planner.py \ + --minutes 240 --stage starting --target-posts 3 --output human +# exit 2 = below the 90-minute floor -> comment-only week returned +# exit 3 = over budget, with the overage named + +python3 ../skills/linkedin-strategy/scripts/newsletter_planner.py \ + --followers 1800 --cadence biweekly --minutes-per-month 420 --output human +``` + +## Discipline + +- **One objective.** Two serve neither; sequence instead of blending. +- **The exclusion list is the positioning.** Do not skip it because it feels negative. +- **Price the week from a bad week.** The plan has to survive week five, not week one. +- **From a standing start, most of the budget belongs in other people's comments.** +- **Review quarterly, not weekly.** A brief revised monthly is a mood. + +## Stop conditions + +- Brief at exit 0 and the cadence plan fits → done; hand over the minimum viable week. +- Cadence over budget twice on the same target → the target is the problem, not the plan. + Say so and cut it. +- Newsletter refused → say why once, recommend posts on a fixed day instead, and stop. + +## Related + +- Skill: [`linkedin-strategy`](../skills/linkedin-strategy/SKILL.md) +- Next: [`/cs:linkedin-post`](cs-linkedin-post.md) once the brief exists diff --git a/marketing/linkedin/commands/cs-linkedin-post.md b/marketing/linkedin/commands/cs-linkedin-post.md new file mode 100644 index 000000000..2f478130b --- /dev/null +++ b/marketing/linkedin/commands/cs-linkedin-post.md @@ -0,0 +1,69 @@ +--- +name: "cs-linkedin-post" +description: "/cs:linkedin-post — Pick the format the material actually supports, draft to the ~140-character mobile fold, and lint 0-100 across mechanics, hook, integrity, and accessibility. Blocking findings for the 3,000-character cap, engagement bait, and Unicode pseudo-bold." +argument-hint: "[the post idea, or paste a draft to be reviewed]" +--- + +# /cs:linkedin-post — Format, draft, lint + +**Command:** `/cs:linkedin-post [idea or draft]` + +## When to run + +- "Write a LinkedIn post about X" / "review my draft" +- "Is this hook any good?" +- "Should this be a carousel or a text post?" + +## When NOT to run + +- No positioning brief yet → [`/cs:linkedin-plan`](cs-linkedin-plan.md) first +- A comment or a DM → [`/cs:linkedin-outreach`](cs-linkedin-outreach.md); different craft +- Repurposing a long source → [`/cs:linkedin-repurpose`](cs-linkedin-repurpose.md) + +## What you get + +1. **A format recommendation** with the constraint it carries — or one question when the top + two score within a point. +2. **A draft** built from your specifics, with a sentence completing inside the first ~140 + characters. +3. **A lint score 0-100** with every finding carrying a fix. +4. **Accessibility done** — alt text written, captions flagged, no pseudo-bold. + +## Workflow + +```bash +# 1. Format from the material, not from fashion +python3 ../skills/linkedin-content/scripts/format_picker.py \ + --goal authority --material data --material tutorial --minutes 120 --output human + +# 2. Draft (the interview comes first: the number, the mistake, the sentence someone said) + +# 3. Lint to a clean exit +python3 ../skills/linkedin-content/scripts/post_linter.py \ + --input draft.md --has-image --output human +# exit 0 SHIP · exit 2 REVISE (or any blocking finding) · exit 3 REWRITE +``` + +## Discipline + +- **Interview before drafting.** A post with no specifics cannot be fixed by editing. +- **Never fabricate a number, client, result, or quote** — not even as a placeholder. +- **Write to the mobile fold.** A sentence completes before character 140. +- **Links in the first comment**, and say so in the post. +- **No engagement bait.** Ask the question the post actually earned. +- **One idea per post.** If it needs two, it is two posts. +- **Cut the first paragraph** and check whether the post starts better at paragraph two. + +## Stop conditions + +- Linter at exit 0 → done. Hand over with "you are the author of record; read every line". +- Linter at exit 2 with only warnings the user has knowingly accepted → done, with the + accepted warnings restated. +- Three REWRITE passes on the same draft → the problem is the idea, not the wording. Go back + to the specifics. + +## Related + +- Agent: [`cs-linkedin-editor`](../agents/cs-linkedin-editor.md) +- Skill: [`linkedin-content`](../skills/linkedin-content/SKILL.md) +- Assets: [`post_templates.md`](../skills/linkedin-content/assets/post_templates.md) diff --git a/marketing/linkedin/commands/cs-linkedin-profile.md b/marketing/linkedin/commands/cs-linkedin-profile.md new file mode 100644 index 000000000..11d54a72c --- /dev/null +++ b/marketing/linkedin/commands/cs-linkedin-profile.md @@ -0,0 +1,64 @@ +--- +name: "cs-linkedin-profile" +description: "/cs:linkedin-profile — Audit a LinkedIn profile 0-100, rank every fix by points per hour, score the headline on five dimensions against the 220-character cap, and assemble an About section that survives the '…see more' fold." +argument-hint: "[your headline, or a description of your profile section by section]" +--- + +# /cs:linkedin-profile — Audit first, rewrite what pays + +**Command:** `/cs:linkedin-profile [headline or profile description]` + +Nothing is fetched. You describe your own profile, or fill in +`skills/linkedin-profile/assets/profile_worksheet.md`. + +## When to run + +- "Fix my headline" / "rewrite my About section" +- "My profile gets views but nothing happens" +- Before any outreach push — comments and DMs drive profile visits, and a weak headline + wastes every one of them + +## What you get + +1. **A completeness score 0-100** across 14 weighted checks, with every gap ranked by points + per hour and a first-hour plan. +2. **A headline score** on audience / outcome / proof / searchability / clarity, plus the + 220-character cap and the front-load check on the first ~60 characters. +3. **An assembled About section** that ends a sentence before the fold and carries audience + or proof above it. +4. **Rewritten experience bullets** — outcomes, not duties. + +## Workflow + +```bash +# 1. Whole profile, fixes ranked by leverage +python3 ../skills/linkedin-profile/scripts/profile_completeness_auditor.py \ + --input profile.json --output human # or --sample to see the shape + +# 2. Headline — iterate to exit 0 +python3 ../skills/linkedin-profile/scripts/headline_scorer.py \ + --headline "..." --output human + +# 3. About — refuses a broken fold, a missing CTA, or an over-length section +python3 ../skills/linkedin-profile/scripts/about_section_builder.py \ + --input about.json --output human +``` + +## Discipline + +- **Never invent a credential, metric, or role.** Everything on a profile is checkable. +- **First person.** Third person on a personal profile reads as a press release. +- **The fold is the section** — whatever sits above "…see more" is what most readers get. +- **Front-load the headline**; the first 60 characters do most of the work. +- Start with the first-hour plan, not with the About section you want to agonise over. + +## Stop conditions + +- Headline at exit 0 (SHIP), or the user would say it out loud to a peer → done. +- About at exit 0, or the user knowingly accepts a named warning → done. +- Auditor at STRONG, or the first-hour plan completed and the rest scheduled → done. + +## Related + +- Skill: [`linkedin-profile`](../skills/linkedin-profile/SKILL.md) +- Run [`/cs:linkedin-plan`](cs-linkedin-plan.md) first if the audience answer is still fuzzy diff --git a/marketing/linkedin/commands/cs-linkedin-repurpose.md b/marketing/linkedin/commands/cs-linkedin-repurpose.md new file mode 100644 index 000000000..a75f58b3f --- /dev/null +++ b/marketing/linkedin/commands/cs-linkedin-repurpose.md @@ -0,0 +1,63 @@ +--- +name: "cs-linkedin-repurpose" +description: "/cs:linkedin-repurpose — Split an article, talk transcript, README, or write-up into standalone LinkedIn units, score each on whether it survives without its context, and skip anything already in the reuse ledger so the same idea never goes out twice." +argument-hint: "[path to the source: article, transcript, README, notes]" +--- + +# /cs:linkedin-repurpose — One source, many posts, no duplicates + +**Command:** `/cs:linkedin-repurpose [path to source]` + +## When to run + +- "Turn my talk into LinkedIn posts" +- "I wrote this article — what can I post from it?" +- You have a body of work and an empty content calendar + +## What you get + +1. **Scored standalone units** — length, dangling references, evidence, substance. A unit + opening with "This meant that…" is disqualified regardless of its score, because it refers + to something the reader never saw. +2. **A suggested format** per unit. +3. **Ledger-aware output** — anything already posted is skipped, with its date available on + request. +4. **The named gap you have to fill** — the first-person sentence only you can write. + +## Workflow + +```bash +# Split and see what is available +python3 ../skills/linkedin-content/scripts/repurpose_splitter.py \ + --input talk.md --ledger .linkedin-ledger.json --output human + +# After publishing unit 2, record it +python3 ../skills/linkedin-content/scripts/repurpose_splitter.py \ + --input talk.md --ledger .linkedin-ledger.json --record 2 --posted-on 2026-08-25 + +# Then lint the drafted post +python3 ../skills/linkedin-content/scripts/post_linter.py --input draft.md --output human +``` + +Commit the ledger alongside the source. It is project state, not a cache. + +## Discipline + +- **Every unit is source material, not a post.** Add what it cost, what you assumed, or what + you would do differently. That sentence is the only genuinely new thing in a repurposed post. +- **Consent and confidentiality first** for post-mortems, customer notes, and anything with a + named third party. The pattern is publishable; the customer is not. +- **Never publish identical text on two platforms the same day.** +- **One source should not carry a quarter.** When units start needing more setup than + payload, the ledger is telling you to go do something new. + +## Stop conditions + +- Usable units identified and the first one drafted and linted clean → done. +- `NOT_SPLITTABLE` (exit 3) → this is one post, not a series. Say so and write the one post. +- All units already in the ledger → the source is mined out. Do not re-cut it. + +## Related + +- Skill: [`linkedin-content`](../skills/linkedin-content/SKILL.md) +- Reference: [`repurposing_discipline.md`](../skills/linkedin-content/references/repurposing_discipline.md) diff --git a/marketing/linkedin/commands/cs-linkedin.md b/marketing/linkedin/commands/cs-linkedin.md new file mode 100644 index 000000000..c06c429a2 --- /dev/null +++ b/marketing/linkedin/commands/cs-linkedin.md @@ -0,0 +1,78 @@ +--- +name: "cs-linkedin" +description: "/cs:linkedin — Route any organic LinkedIn goal to the right lane (profile / strategy / content / engagement / analytics) after gating it against LinkedIn's User Agreement. Refuses automation, scraping, engagement pods, bulk DMs, fake identity, and fabricated proof before a word is drafted. Nothing is ever sent." +argument-hint: "[what you want from LinkedIn — a goal, a profile problem, a post, an outreach plan, or a question about your numbers]" +--- + +# /cs:linkedin — Gate, then route + +**Command:** `/cs:linkedin [your goal]` + +This plugin holds no LinkedIn credentials, makes no API calls, and sends nothing. Every +output is text you paste and post yourself. + +## When to run + +- "Help me grow on LinkedIn" / "I want to build an audience" +- You do not know which part of the problem to work on first +- Any LinkedIn request where you want the platform rules checked before the work starts + +## When NOT to run + +- You already know the lane → go straight to `/cs:linkedin-profile`, `/cs:linkedin-plan`, + `/cs:linkedin-post`, `/cs:linkedin-outreach`, or `/cs:linkedin-analyze` +- X/Twitter → `marketing-skill/x-twitter-growth` +- Multi-platform brand social calendars → `marketing-skill/social-content` +- Cold email → `marketing-skill/cold-email` + +## What you get + +1. **A policy verdict** — ALLOW, CONSTRAIN (with the constraint stated), or REFUSE (with the + rule named and a compliant substitute offered). +2. **A route** — one of five lanes, with the matched signals, or one clarifying question when + two lanes are genuinely close. +3. **Forcing questions**, one at a time with a recommended answer, until the lane can run + honestly. +4. **The lane's artifact** — audit, brief, draft, roster, or analysis. + +## Workflow + +```bash +# 1. Gate first, always +python3 ../skills/linkedin-skills/scripts/linkedin_policy_gate.py \ + --text "" --output human +# exit 4 REFUSE -> name the rule, offer the substitute, route there instead +# exit 3 CONSTRAIN -> proceed and state the constraint out loud +# exit 0 ALLOW -> proceed + +# 2. Route +python3 ../skills/linkedin-skills/scripts/linkedin_goal_router.py \ + --text "" --output human +# exit 0 route · exit 2 ask ONE question · exit 3 ask what they want to walk away with +``` + +## Trigger phrases (auto-invoke without /cs:) + +"grow my LinkedIn" · "LinkedIn strategy" · "build an audience on LinkedIn" · +"my LinkedIn isn't working" · "what should I post on LinkedIn" + +## Discipline + +- **Gate before route, route before draft.** A REFUSE outranks any route. +- **Never silently chain lanes.** Offer the next one as a question with a recommendation. +- **Evidence-graded claims.** 🟢 LinkedIn-official / 🟡 third-party study / 🔴 folklore, and + folklore gets named rather than repeated. +- **Nothing is fetched or sent.** + +## Stop conditions + +- Lane delivered its artifact and the user knows the one thing only they can do next → done. +- REFUSE delivered with a substitute offered, and the user declines the substitute → done. + Do not build a partial version of the refused thing. +- Router returns NO_SIGNAL twice on the same input → hand the question back plainly. + +## Related + +- Agent: [`cs-linkedin-orchestrator`](../agents/cs-linkedin-orchestrator.md) +- Skill: [`linkedin-skills`](../skills/linkedin-skills/SKILL.md) +- Siblings: [`/cs:grill-linkedin`](cs-grill-linkedin.md) and the six lane commands diff --git a/marketing/linkedin/skills/linkedin-analytics/SKILL.md b/marketing/linkedin/skills/linkedin-analytics/SKILL.md new file mode 100644 index 000000000..873512a9b --- /dev/null +++ b/marketing/linkedin/skills/linkedin-analytics/SKILL.md @@ -0,0 +1,100 @@ +--- +name: linkedin-analytics +description: Use when someone wants to understand their own LinkedIn numbers — which posts worked, why reach dropped, whether a pattern is real, or how to test a hypothesis. Triggers on "why did my reach drop", "what's working on my LinkedIn", "analyze my posts", "do carousels do better for me", "should I test this", "LinkedIn analytics". Reads your own exported post data, reports medians and outlier bands, tests candidate patterns against a permutation null, and sizes a real experiment — refusing to conclude anything below 10 posts. +license: MIT +metadata: + version: 1.0.0 + author: Alireza Rezvani + category: marketing + updated: 2026-08-25 +--- + +# LinkedIn Analytics — describe honestly, then refuse to over-conclude + +The characteristic sentence of LinkedIn analytics is "carousels do 3x better for me", built +on four posts. With engagement as heavy-tailed as it is, four posts will show a 3x difference +between almost any two groups you care to define. These three scripts stop that sentence +becoming a strategy. + +**Your own data only.** Nothing is fetched; scraping post or profile data is prohibited by +User Agreement §8.2 and none of this analysis needs it. + +## Workflow + +**1. Get the export.** LinkedIn Analytics → Post impressions → Export, or Settings → Data +privacy → Get a copy of your data. CSV and JSON both work. + +**2. Describe it.** Exit 0 analysed / 2 below the 10-post floor, descriptive only / 3 +unusable. Reports median and MAD rather than mean and standard deviation — one breakout post +makes a mean describe a distribution none of your posts belong to — plus Tukey percentile +bands and a 1.5×IQR breakout threshold, so "this did well" has a number behind it. + +```bash +python3 scripts/post_performance_analyzer.py --input posts.csv --csv --output human +``` + +**3. Test the pattern they think they see.** + +```bash +python3 scripts/pattern_miner.py --input posts.json --output human +``` + +Exit 0 something survived / 2 nothing survived / 3 under 10 posts. Four gates: 5 posts in and +5 out; a 15% relative difference in medians; beating 90% of 2,000 seeded label shuffles; and +a multiple-comparisons accounting of how many candidates would pass on noise alone. + +**"Nothing survived" is the most common honest answer and it is a real finding.** Report it +as one. Do not soften it into a hedge that reads like a conclusion. + +**4. Turn a survivor into a test.** + +```bash +python3 scripts/experiment_planner.py --hypothesis "..." --variable "..." \ + --cv 0.45 --effect 0.30 --posts-per-week 2 --max-weeks 12 --output human +``` + +CV comes from step 2: `1.4826 * MAD / median`. Exit 0 feasible / 2 too long, with the minimum +detectable effect in their window / 3 refused. It will frequently say the test needs more +posts than a quarter allows — **that is the honest answer**, and more useful than a confident +conclusion from retrospective data. + +## Rules + +- **Under 10 posts, describe; do not conclude.** Say so plainly. +- **A pattern in past posts is a hypothesis.** Retrospective data is confounded — you made + carousels when you had structured material, on topics you knew best, in weeks you had time. + No statistics on the same data removes that. +- **Never benchmark against someone else's numbers.** Different denominator, different + audience, usually a vendor's sample. +- **Follower count is not a success metric.** Track inbound conversations, specific + references, invitations — the Tier 1 metrics you count by hand. +- **Report the confidence level.** LinkedIn-official 🟢, third-party study 🟡, folklore 🔴. +- **One good post is not evidence.** It is the most common cause of a strategy change and the + least informative event available. + +## Scripts + +| Script | Role | +|---|---| +| [`scripts/post_performance_analyzer.py`](scripts/post_performance_analyzer.py) | Median/MAD, percentile bands, IQR outlier fence, per-post BREAKOUT→DUD classification; refuses conclusions below 10 posts. | +| [`scripts/pattern_miner.py`](scripts/pattern_miner.py) | Four-gate permutation test with multiple-comparisons accounting; reports why every rejected candidate failed. | +| [`scripts/experiment_planner.py`](scripts/experiment_planner.py) | Sizes a two-arm posting experiment, names the confounds to hold constant, and writes the falsification condition before the first post. | + +## References and assets + +- [`references/linkedin_metrics_canon.md`](references/linkedin_metrics_canon.md) — what each number is, what it is not, and which three tiers to track (7 sources) +- [`references/evidence_thresholds.md`](references/evidence_thresholds.md) — the four gates, forking paths, and the uncomfortable arithmetic of LinkedIn A/B tests (7 sources) + +- [`assets/example_post_export.csv`](assets/example_post_export.csv) — a 12-post export in the expected shape +- [`assets/measurement_log_template.md`](assets/measurement_log_template.md) — the Tier 1 outcome log you keep by hand + +## Distinct from + +- **`marketing-skill/social-media-analyzer`** — cross-platform brand campaign reporting. This + is one person's own LinkedIn export, with refusals attached. +- **`linkedin-strategy`** — decides what to do next. This says what happened. +- **`product-team/experiment-designer`** — product A/B tests with real traffic; here n is + posts, and usually too small. + +--- +**Version:** 1.0.0 diff --git a/marketing/linkedin/skills/linkedin-analytics/assets/example_post_export.csv b/marketing/linkedin/skills/linkedin-analytics/assets/example_post_export.csv new file mode 100644 index 000000000..6da0408d7 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-analytics/assets/example_post_export.csv @@ -0,0 +1,13 @@ +date,title,impressions,reactions,comments,reposts,format,pillar,chars +2026-05-04,Onboarding 6 weeks to 4 days,8120,214,63,11,text-post,trust-debt,1450 +2026-05-11,dbt exposures rollout,3050,61,9,2,text-post,modelling,620 +2026-05-18,Hiring the first analytics engineer,2110,39,6,1,text-post,hiring,540 +2026-05-25,Cost teardown carousel,5400,132,28,9,document-carousel,trust-debt,900 +2026-06-01,Why we deleted the kickoff call,4300,97,24,5,text-post,trust-debt,1610 +2026-06-08,Modelling tradeoffs,1780,28,3,0,text-post,modelling,480 +2026-06-15,Analytics engineer job spec,2450,44,11,2,text-post,hiring,700 +2026-06-22,Queue time carousel,6900,168,41,14,document-carousel,trust-debt,1100 +2026-06-29,Three dbt anti-patterns,3600,74,15,3,document-carousel,modelling,950 +2026-07-06,What I got wrong about capacity,5100,121,33,6,text-post,trust-debt,1720 +2026-07-13,Interview loop for analytics engineers,1950,31,4,1,text-post,hiring,510 +2026-07-20,Freshness SLAs,2800,52,12,2,text-post,modelling,820 diff --git a/marketing/linkedin/skills/linkedin-analytics/assets/measurement_log_template.md b/marketing/linkedin/skills/linkedin-analytics/assets/measurement_log_template.md new file mode 100644 index 000000000..f0aa927af --- /dev/null +++ b/marketing/linkedin/skills/linkedin-analytics/assets/measurement_log_template.md @@ -0,0 +1,64 @@ +# Measurement Log + +The Tier 1 metrics — the ones tied to your objective — are not in LinkedIn's analytics. They +have to be written down as they happen, which is why almost nobody has them and why the +people who do can actually tell whether the work is working. + +Five minutes a week. Keep it in the repo or wherever the positioning brief lives. + +--- + +## Tier 1 — outcomes (count these by hand) + +| Date | What happened | Traceable to | Objective advanced? | +|---|---|---|---| +| | Inbound conversation started by them | post / comment / profile / referral | | +| | Someone referenced a specific post in their first message | | | +| | Invitation (podcast, panel, guest post, talk) | | | +| | Referral or intro offered without asking | | | +| | Qualified enquiry (they named budget, timeline, or a scoped problem) | | | + +Attribution is never clean. Record what they said, not what you infer. + +## Tier 2 — behavioural proxies (weekly) + +| Week | Posts | Median comments/post | Comment share (comments ÷ total interactions) | Notes | +|---|---|---|---|---| +| | | | | | + +Comment share is the cleanest available proxy for whether the work lands with people who +care: a comment costs a reader thirty seconds and a small reputational exposure; a reaction +costs a tap. + +## Tier 3 — reach (monthly, to notice large changes only) + +| Month | Posts | Median impressions | Median engagement rate | Followers | What changed | +|---|---|---|---|---|---| +| | | | | | | + +Do not make weekly decisions on these. They are noisy, redefined without notice, and easy to +move in ways that do not serve the objective. + +## Quarterly review + +Against the 90-day criteria in the positioning brief: + +- [ ] Criterion 1: ______ — met / not met +- [ ] Criterion 2: ______ — met / not met +- [ ] Criterion 3: ______ — met / not met + +Then three questions: + +1. Which pillar produced the **outcomes**, as opposed to the engagement? +2. Did the experimental pillar earn promotion, or should it be replaced? +3. Has the audience description got vaguer? (It always drifts broader. Drag it back.) + +## Re-run the tools + +```bash +python3 ../scripts/post_performance_analyzer.py --input export.csv --csv --output human +python3 ../scripts/pattern_miner.py --input export.csv --csv --output human +``` + +Expect `NOTHING_SURVIVED` most times. That is what honest analysis of a small sample looks +like, and it is a finding — not a reason to keep slicing the data until something passes. diff --git a/marketing/linkedin/skills/linkedin-analytics/references/evidence_thresholds.md b/marketing/linkedin/skills/linkedin-analytics/references/evidence_thresholds.md new file mode 100644 index 000000000..8558b5dbb --- /dev/null +++ b/marketing/linkedin/skills/linkedin-analytics/references/evidence_thresholds.md @@ -0,0 +1,132 @@ +# Evidence Thresholds — why most LinkedIn "insights" are noise + +The characteristic sentence of LinkedIn analytics is: "carousels do 3x better for +me." It is usually built on four posts. With engagement as heavy-tailed as it is, +four posts will show a 3x difference between almost any two groups you care to +define — including groups defined by the first letter of the first word. + +`pattern_miner.py` exists to kill that sentence before it becomes a strategy. + +--- + +## The four gates + +A candidate pattern has to pass all four. Most fail at the first. + +### 1. Group size floor — at least 5 in, 5 out +Below five, the median is one or two posts and any difference is a coin flip. +Reported as `NOT_TESTED`, with the counts, because "you do not have enough data +yet" is a real and useful finding. + +### 2. Effect floor — 15% relative difference in medians +A statistically detectable 3% difference is not a decision. If you would not +change what you write over it, testing it is a waste of the posts. The floor is +set at the level where a rational person would actually act. + +### 3. Permutation test — beat 90% of 2,000 label shuffles +The labels ("carousel" / "not carousel") are shuffled 2,000 times against a fixed +seed, the difference of medians recomputed each time, and the observed difference +must be larger than at least 90% of them. + +A permutation test is the right instrument here because it makes **no +distributional assumption**. A t-test assumes something approximately normal; +engagement rate is not. The permutation null asks the only question that matters: +*given these exact numbers, how often would random labelling produce a gap this +big?* + +The seed is fixed, so the same data always produces the same verdict. An analysis +tool that returns a different answer on re-run is not an analysis tool. + +### 4. Multiple comparisons — count every test, report the expected false positives +This is the gate nobody else implements, and it is the one that catches the most +self-deception. + +If you test twenty candidate patterns at α = 0.10, **two will pass on noise +alone**. That is not a flaw in the method; it is what the threshold means. The +miner reports how many candidates reached the test, how many you would expect to +pass by chance, and how many actually did. When those numbers are close, it says +so. + +It also skips mirrored candidates: for a two-value attribute, "carousel vs rest" +and "text vs rest" are the same comparison with the sign flipped, and counting +both would double-count it in the accounting. + +Gelman and Loken's "garden of forking paths" is the sharper version of the +problem: even without formally testing twenty hypotheses, an analyst who *would +have* tested a different cut had the data looked different is effectively +multiple-testing. The defence is to declare the cuts in advance — which is why +the miner takes a fixed attribute list rather than searching for whatever splits +best. + +## Why a found pattern is a hypothesis, not a finding + +Everything the miner reports is retrospective. It found a difference in posts you +already wrote, chosen for reasons that correlate with everything else about them: +you probably made carousels when you had structured material, on topics you knew +best, in weeks when you had time. + +That is confounding, and no amount of statistics on the same dataset removes it. + +The only way to get a finding is a deliberate test: decide the variable in +advance, alternate the arms, hold the confounds constant, and run the window. +`experiment_planner.py` sizes it — and will frequently tell you the test needs +more posts than you can produce in a quarter. **That is an honest answer**, and +it is more useful than a confident conclusion from retrospective data. + +## The uncomfortable arithmetic + +At a realistic coefficient of variation (0.35-0.6 for most accounts) and a +30% target effect, a two-arm test needs roughly 20-60 posts. At two posts a week, +that is 5-30 weeks. + +Which means: **most of the LinkedIn A/B tests people describe are not runnable at +their actual posting volume.** The honest responses are to test only variables +where you expect a large effect, to accept a large minimum detectable effect and +say so, or to stop testing and write the thing you would rather write. + +## Things that are not evidence + +- **One post that did well.** The single most common cause of a strategy change, + and the least informative event available. A breakout post tells you a specific + post worked, in a specific week, with a specific audience state. +- **Comparing this month to last month.** Confounded by season, news cycle, + audience growth, and LinkedIn product changes, all at once. +- **Someone else's benchmark.** Different denominator, different audience, + usually a vendor's sample. +- **A pattern that appeared after you went looking for one.** See forking paths + above. + +## What to do instead of measuring more + +Post consistently for a quarter against a brief. Track the Tier 1 outcome metrics +by hand — conversations, references, invitations. Re-run the miner every six +weeks and expect it to say "nothing survived" most times, because that is what +honest analysis of a small sample looks like. + +The compounding comes from consistency, not from optimisation, and the +optimisation is unavailable at this sample size anyway. + +--- + +## Sources + +1. Good, P. **Permutation, Parametric, and Bootstrap Tests of Hypotheses** + (3rd ed.) — the permutation framework and its distribution-free guarantee. +2. Gelman, A. & Loken, E. **"The Garden of Forking Paths"** (2013) — why + researcher degrees of freedom produce false positives without any explicit + p-hacking. +3. Cohen, J. **Statistical Power Analysis for the Behavioral Sciences** (2nd ed.) + — the two-sample sizing formula used by `experiment_planner.py`, and the case + for declaring a minimum effect of interest. +4. Tukey, J. **Exploratory Data Analysis** (1977) — robust summaries, and the + distinction between exploratory and confirmatory analysis that this whole + document rests on. +5. Ioannidis, J. **"Why Most Published Research Findings Are False."** *PLoS + Medicine*, 2005 — the relationship between small samples, many tests, and + false discovery. +6. Benjamini, Y. & Hochberg, Y. **"Controlling the False Discovery Rate."** + *JRSS-B*, 1995 — the formal treatment of the multiple-comparisons accounting + the miner reports informally. +7. Taleb, N.N. **Statistical Consequences of Fat Tails** (2020) — why sample + means and standard deviations mislead for heavy-tailed processes, and why + medians are used throughout. diff --git a/marketing/linkedin/skills/linkedin-analytics/references/linkedin_metrics_canon.md b/marketing/linkedin/skills/linkedin-analytics/references/linkedin_metrics_canon.md new file mode 100644 index 000000000..18aa61680 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-analytics/references/linkedin_metrics_canon.md @@ -0,0 +1,136 @@ +# LinkedIn Metrics Canon — what each number is, and what it is not + +LinkedIn gives creators a small set of numbers and almost no definitions. The +result is a advice ecosystem built on metrics whose meaning has changed at least +twice, compared across periods where they meant different things. + +This is what each one actually is, and which ones are worth tracking. + +--- + +## The numbers LinkedIn shows you + +### Impressions +The count of times a post was rendered in a feed. **Not unique people.** One +person scrolling past your post twice can produce two impressions. LinkedIn has +revised the counting rules (notably around what counts as "viewed") more than +once, without a changelog a creator can read. + +Consequence: **compare posts from the same period, not across a year.** A 2024 +impression and a 2026 impression are not obviously the same object. + +### Members reached +Where available, this is closer to unique people and is the better denominator. +It is not exposed everywhere, which is why engagement rate is usually computed on +impressions. + +### Reactions, comments, reposts +Counts of the three interaction types. Reposts split into "repost" and "repost +with your thoughts", which behave differently and are usually reported together. + +### Engagement rate +LinkedIn does not publish a single definition, and neither does anyone else +consistently. This plugin uses: + +``` +engagement_rate = (reactions + comments + reposts) / impressions +``` + +Stated explicitly because the alternative definitions (including clicks, +including follows, dividing by followers) produce numbers that differ by 3-5x. +**Any benchmark you read elsewhere is on an unknown denominator.** Compare +against your own history, not against a published benchmark. + +### Profile views +Weakly attributable and heavily lagged. Useful as a trend across weeks, useless +per post. + +### Followers gained +The metric people optimise and should not. It moves for reasons unrelated to +whether the work is working: one post reaching an adjacent audience adds +followers who will never engage again. A quarter of excellent, well-targeted work +can add very few. + +--- + +## What to actually track + +Three tiers, in descending reliability. + +**Tier 1 — outcome metrics.** Inbound conversations, specific references +("I saw your post on X"), invitations, referrals, qualified enquiries. Counted by +hand, in a note. These are the only numbers tied to the objective, and they are +the ones nobody tracks because they require writing things down. + +**Tier 2 — behavioural proxies.** Comment count and comment *share* +(comments / total interactions). A comment costs a reader thirty seconds and a +small reputational exposure; a reaction costs a tap. Comment share is the +cleanest available proxy for whether the work is landing with people who care. + +**Tier 3 — reach metrics.** Impressions, engagement rate, followers. Noisy, +redefined without notice, and easy to move in ways that do not serve the +objective. Track them to notice large changes, not to make weekly decisions. + +## Why median, not mean + +`post_performance_analyzer.py` reports median and MAD (median absolute +deviation), not mean and standard deviation. LinkedIn post performance is +heavy-tailed: a small number of posts reach far outside the normal range, and one +of them drags a mean to a value that describes none of your posts. + +The bands the analyzer reports are Tukey's: Q1, Q3, and a 1.5×IQR fence above Q3 +for "breakout". This is the standard robust definition of an outlier and it means +"breakout" has a threshold rather than a feeling. + +## The floor + +The analyzer refuses to characterise a body of work under **10 posts**, and the +pattern miner refuses to test anything under 10. + +This is not conservatism. The between-post variance on LinkedIn is routinely +larger than any group difference eight posts could show — which means with eight +posts you can "discover" almost any pattern you go looking for. See +`evidence_thresholds.md`. + +## Exporting your own data + +LinkedIn provides two routes, both to *your own* data: + +- **Analytics → Post impressions → Export** for per-post performance. +- **Settings → Data privacy → Get a copy of your data** for a fuller archive. + +Both tools in this skill read these exports. **Nothing is fetched from LinkedIn +and no other member's data is involved** — scraping profiles or post data is +prohibited by User Agreement §8.2, and there is no version of this analysis that +needs it. + +## Benchmarks against other people + +Don't. Three reasons, any one of which is sufficient: + +1. The denominators differ and are usually unstated. +2. Audience composition dominates. A 12% engagement rate on 400 followers who + all know you personally is not comparable to 2% on 20,000. +3. The published benchmarks come from samples of accounts that opted into being + measured, usually by a vendor selling to them. + +Your own history is the only honest baseline, which is another argument for +posting consistently enough to have one. + +--- + +## Sources + +1. LinkedIn Help. **Analytics and post performance** documentation — the + definitions LinkedIn does publish, and the export path. +2. LinkedIn Help. **"Get a copy of your data"** — the supported self-export. +3. LinkedIn. **User Agreement §8.2** — why analysis here is limited to your own + exports. +4. Tukey, J. **Exploratory Data Analysis** (1977) — median, IQR, and the 1.5×IQR + outlier fence used for the band thresholds. +5. Huber, P. & Ronchetti, E. **Robust Statistics** (2nd ed.) — MAD as a robust + scale estimate, and the 1.4826 consistency constant used to derive CV. +6. Taleb, N.N. **The Black Swan** / **Statistical Consequences of Fat Tails** — + why sample means are unreliable descriptors for heavy-tailed processes. +7. Nielsen, J. **"The 90-9-1 Rule for Participation Inequality"** (NN/g) — the + structural reason interaction counts are small and skewed relative to reach. diff --git a/marketing/linkedin/skills/linkedin-analytics/scripts/experiment_planner.py b/marketing/linkedin/skills/linkedin-analytics/scripts/experiment_planner.py new file mode 100644 index 000000000..98d68f8bb --- /dev/null +++ b/marketing/linkedin/skills/linkedin-analytics/scripts/experiment_planner.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""experiment_planner.py — turn a LinkedIn hypothesis into a test you could actually lose. + +A pattern found in past posts is a hypothesis. This sizes the deliberate test: +how many posts per arm, how many weeks that takes at your cadence, what to hold +constant, and — the part that gets skipped — what result would make you abandon +the idea. + +Sizing uses the standard two-sample formula with the coefficient of variation of +your own engagement rate: + + n per arm = 2 * (z_alpha/2 + z_beta)^2 * CV^2 / effect^2 + +Get CV from post_performance_analyzer.py: a robust estimate is +1.4826 * MAD / median. It is typically 0.3-0.6 for an established account and +higher for a new one, which is why honest LinkedIn experiments need more posts +than people expect. + +Treat the number as a planning estimate, not inference. Engagement rate is +heavy-tailed and posts are not independent draws — the same week's news moves all +of them. Analyse the result with pattern_miner.py's permutation test rather than +a t-test. + +Exit codes: + 0 FEASIBLE — the test fits the window + 2 TOO_LONG — does not fit; the minimum detectable effect in the window is returned + 3 REFUSED — the hypothesis or the effect size is not worth testing + +Stdlib only. No network. Deterministic. +""" + +import argparse +import json +import math +import sys + +# Two-sided z for alpha, one-sided z for power. Small table, no scipy. +Z_ALPHA = {0.20: 1.282, 0.10: 1.645, 0.05: 1.960} +Z_POWER = {0.70: 0.524, 0.80: 0.842, 0.90: 1.282} + +MIN_ACTIONABLE_EFFECT = 0.10 + +CONFOUNDS = [ + "Post at the same time of day in both arms. Time of day and day of week move " + "engagement more than most of the variables people test.", + "Alternate arms post by post. Running arm A for a month and arm B for the next month " + "tests the month, not the variable.", + "Hold the pillar mix steady. If arm A is all your strongest topic, you measured the topic.", + "Do not change the profile, headline, or posting cadence mid-test.", + "Log the arm before you publish, not after. Deciding which arm a post belonged to once " + "you have seen the numbers is how every informal test goes wrong.", +] + +# Chosen to be feasible so the sample shows the full happy path. Note how narrow +# the feasible region is: at CV 0.45 and a 30% target effect this same test needs +# 28 weeks. Most LinkedIn "A/B tests" people describe are not runnable at their +# actual posting volume, and the tool says so rather than pretending otherwise. +SAMPLE = { + "hypothesis": "Document carousels earn a higher engagement rate than text posts for my audience", + "variable": "format (document-carousel vs text-post)", + "baseline_median_er": 0.025, + "cv": 0.35, + "effect": 0.40, + "posts_per_week": 3, + "max_weeks": 16, +} + + +def plan(hypothesis: str, variable: str, cv: float, effect: float, + posts_per_week: float, max_weeks: int, alpha: float, power: float, + baseline: float) -> dict: + if not hypothesis.strip() or not variable.strip(): + return {"verdict": "REFUSED", "exit_code": 3, + "finding": "No hypothesis or no named variable.", + "fix": "State it as a sentence you could be wrong about: 'X earns a higher " + "engagement rate than Y for my audience.' If you cannot say what would " + "disprove it, it is a preference, not a hypothesis."} + if effect < MIN_ACTIONABLE_EFFECT: + return {"verdict": "REFUSED", "exit_code": 3, + "finding": f"A {effect:.0%} relative effect is below the " + f"{MIN_ACTIONABLE_EFFECT:.0%} floor.", + "fix": "Test something you would actually change your approach over. Detecting a " + "5% difference needs hundreds of posts and would not change a single " + "decision when you found it."} + if cv <= 0: + return {"verdict": "REFUSED", "exit_code": 3, + "finding": "Coefficient of variation must be positive.", + "fix": "Run post_performance_analyzer.py and compute 1.4826 * MAD / median. " + "With fewer than 10 posts you do not have a usable CV yet."} + + z_a = Z_ALPHA[alpha] + z_b = Z_POWER[power] + n_per_arm = math.ceil(2 * (z_a + z_b) ** 2 * cv ** 2 / effect ** 2) + total = n_per_arm * 2 + weeks = math.ceil(total / posts_per_week) if posts_per_week > 0 else 10 ** 6 + + common = { + "hypothesis": hypothesis.strip(), + "variable": variable.strip(), + "design": { + "arms": 2, "alpha": alpha, "power": power, "cv": cv, + "target_relative_effect": effect, + "baseline_median_engagement_rate": baseline, + "posts_per_arm": n_per_arm, "total_posts": total, + "weeks_at_cadence": weeks, "posts_per_week": posts_per_week, + }, + "hold_constant": CONFOUNDS, + "analysis_rule": "Analyse with pattern_miner.py (difference of medians, permutation " + "test). Do not use a t-test: engagement rate is heavy-tailed and one " + "breakout post will carry a mean on its own.", + "caveat": "Posts are not independent draws — a busy news week moves every arm at once. " + "This sizing is a planning aid, not a guarantee of power.", + } + + if weeks > max_weeks: + affordable_per_arm = max(1, int(max_weeks * posts_per_week / 2)) + mde = math.sqrt(2 * (z_a + z_b) ** 2 * cv ** 2 / affordable_per_arm) + return {**common, "verdict": "TOO_LONG", "exit_code": 2, + "finding": f"{total} posts at {posts_per_week}/week is {weeks} weeks, past the " + f"{max_weeks}-week window.", + "options": [ + f"Accept a bigger minimum detectable effect: in {max_weeks} weeks you can " + f"detect about {mde:.0%} relative, not {effect:.0%}. If a {mde:.0%} " + "difference would still change your decision, run it.", + "Raise the cadence — but only if the cadence is sustainable for the whole " + "window; an abandoned test is worse than none.", + "Accept lower power (0.70) and treat the result as directional.", + "Do not run it, and pick the option you would rather write anyway. Not every " + "question is worth a quarter of your output.", + ], + "minimum_detectable_effect_in_window": round(mde, 3), + "stop_rule": "If you change the plan mid-test, the test is over. Start again or " + "accept the result as anecdote."} + + return {**common, "verdict": "FEASIBLE", "exit_code": 0, + "schedule": f"{n_per_arm} posts per arm, alternating, {posts_per_week}/week — " + f"{weeks} weeks.", + "falsification": f"If the median engagement rate of the {variable.strip()} arm is not " + f"at least {effect:.0%} above the other arm at the end of the window, " + "the hypothesis failed. Write that down now, before the first post.", + "stop_rule": [ + "Run the full window. Stopping early because the numbers look good is how a " + "coin flip becomes a strategy.", + "One exception: stop if something outside the test changes — a job change, a " + "viral post, a LinkedIn product change. Then restart rather than salvage.", + ]} + + +def render_human(r: dict) -> str: + if r["verdict"] == "REFUSED": + return (f"Experiment: REFUSED\n{'=' * 44}\n{r['finding']}\nfix → {r['fix']}") + d = r["design"] + lines = [f"Experiment: {r['verdict']}", "=" * 60, + f"Hypothesis : {r['hypothesis']}", + f"Variable : {r['variable']}", + f"Design : 2 arms, alpha {d['alpha']}, power {d['power']}, CV {d['cv']}, " + f"target effect {d['target_relative_effect']:.0%}", + f"Sample : {d['posts_per_arm']} posts/arm ({d['total_posts']} total) = " + f"{d['weeks_at_cadence']} weeks at {d['posts_per_week']}/week", ""] + if r["verdict"] == "TOO_LONG": + lines += [r["finding"], "", "Options:"] + for o in r["options"]: + lines.append(f" - {o}") + lines += ["", f"Stop rule: {r['stop_rule']}"] + else: + lines += [f"Schedule : {r['schedule']}", "", + f"Falsification: {r['falsification']}", "", "Stop rule:"] + for s in r["stop_rule"]: + lines.append(f" - {s}") + lines.append("\nHold constant:") + for c in r["hold_constant"]: + lines.append(f" - {c}") + lines += ["", r["analysis_rule"], "", r["caveat"]] + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Size a LinkedIn posting experiment " + "(feasible=0 / too-long=2 / refused=3).") + ap.add_argument("--hypothesis", default="", help="The claim, stated so it can fail.") + ap.add_argument("--variable", default="", help="The one thing that differs between arms.") + ap.add_argument("--cv", type=float, default=0.0, + help="Coefficient of variation of your engagement rate " + "(1.4826 * MAD / median from post_performance_analyzer.py).") + ap.add_argument("--effect", type=float, default=0.30, + help="Relative effect you would act on (default 0.30 = 30%%).") + ap.add_argument("--posts-per-week", type=float, default=2.0) + ap.add_argument("--max-weeks", type=int, default=12) + ap.add_argument("--alpha", type=float, choices=sorted(Z_ALPHA), default=0.10) + ap.add_argument("--power", type=float, choices=sorted(Z_POWER), default=0.80) + ap.add_argument("--baseline-er", type=float, default=0.0, + help="Your median engagement rate, for reference in the output.") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Size a built-in example experiment.") + args = ap.parse_args() + + if args.sample: + s = SAMPLE + result = plan(s["hypothesis"], s["variable"], s["cv"], s["effect"], + s["posts_per_week"], s["max_weeks"], 0.10, 0.80, + s["baseline_median_er"]) + else: + if not args.hypothesis: + ap.error("--hypothesis is required (or use --sample)") + result = plan(args.hypothesis, args.variable, args.cv, args.effect, + args.posts_per_week, args.max_weeks, args.alpha, args.power, + args.baseline_er) + + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-analytics/scripts/pattern_miner.py b/marketing/linkedin/skills/linkedin-analytics/scripts/pattern_miner.py new file mode 100644 index 000000000..120386ebf --- /dev/null +++ b/marketing/linkedin/skills/linkedin-analytics/scripts/pattern_miner.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""pattern_miner.py — test whether an apparent LinkedIn pattern survives a null test. + +The standard failure of LinkedIn analytics is a confident sentence built on four +posts: "carousels do 3x better for you". With post engagement as heavy-tailed as +it is, four posts will show a 3x difference between almost any two groups you +care to define. + +So this tool tries to kill every candidate pattern before reporting it: + + 1. Group size floor — at least 5 posts in the group and 5 outside it. + 2. Effect floor — the median engagement rate must differ by at least 15% + relative. A statistically detectable 3% difference is not a decision. + 3. Permutation test — labels are shuffled a fixed number of times against a + fixed seed, and the observed difference must beat at least 90% of the + shuffles. Same data in, same verdict out. + 4. Multiple-comparisons note — every candidate tested is counted, and the + expected number of false positives at the chosen threshold is reported + alongside the number that passed. If you test twenty things at p<0.10, two + will "pass" on noise alone, and the report says so. + +Every rejected candidate is reported with the reason it failed, because "not +enough data yet" is the finding most of the time and it is a useful one. + +Input: the same export shape as post_performance_analyzer.py. + +Exit codes: + 0 at least one candidate survived + 2 nothing survived — reasons listed per candidate + 3 not enough posts to test anything (under 10) + +Stdlib only. No network. Deterministic (fixed seed). +""" + +import argparse +import csv +import datetime +import io +import json +import random +import sys + +MIN_GROUP = 5 +MIN_RELATIVE_EFFECT = 0.15 +ALPHA = 0.10 +SHUFFLES = 2000 +SEED = 20260825 +MIN_POSTS = 10 + +WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + +SAMPLE = [ + {"date": "2026-05-04", "impressions": 8120, "reactions": 214, "comments": 63, "reposts": 11, + "format": "text-post", "pillar": "trust-debt", "chars": 1450}, + {"date": "2026-05-06", "impressions": 3050, "reactions": 61, "comments": 9, "reposts": 2, + "format": "text-post", "pillar": "modelling", "chars": 620}, + {"date": "2026-05-11", "impressions": 2110, "reactions": 39, "comments": 6, "reposts": 1, + "format": "text-post", "pillar": "hiring", "chars": 540}, + {"date": "2026-05-13", "impressions": 5400, "reactions": 132, "comments": 28, "reposts": 9, + "format": "document-carousel", "pillar": "trust-debt", "chars": 900}, + {"date": "2026-05-18", "impressions": 4300, "reactions": 97, "comments": 24, "reposts": 5, + "format": "text-post", "pillar": "trust-debt", "chars": 1610}, + {"date": "2026-05-20", "impressions": 1780, "reactions": 28, "comments": 3, "reposts": 0, + "format": "text-post", "pillar": "modelling", "chars": 480}, + {"date": "2026-05-25", "impressions": 2450, "reactions": 44, "comments": 11, "reposts": 2, + "format": "text-post", "pillar": "hiring", "chars": 700}, + {"date": "2026-05-27", "impressions": 6900, "reactions": 168, "comments": 41, "reposts": 14, + "format": "document-carousel", "pillar": "trust-debt", "chars": 1100}, + {"date": "2026-06-01", "impressions": 3600, "reactions": 74, "comments": 15, "reposts": 3, + "format": "document-carousel", "pillar": "modelling", "chars": 950}, + {"date": "2026-06-03", "impressions": 5100, "reactions": 121, "comments": 33, "reposts": 6, + "format": "text-post", "pillar": "trust-debt", "chars": 1720}, + {"date": "2026-06-08", "impressions": 1950, "reactions": 31, "comments": 4, "reposts": 1, + "format": "text-post", "pillar": "hiring", "chars": 510}, + {"date": "2026-06-10", "impressions": 2800, "reactions": 52, "comments": 12, "reposts": 2, + "format": "text-post", "pillar": "modelling", "chars": 820}, + {"date": "2026-06-15", "impressions": 7400, "reactions": 181, "comments": 47, "reposts": 12, + "format": "document-carousel", "pillar": "trust-debt", "chars": 1050}, + {"date": "2026-06-17", "impressions": 2300, "reactions": 40, "comments": 5, "reposts": 1, + "format": "text-post", "pillar": "hiring", "chars": 560}, + {"date": "2026-06-22", "impressions": 6100, "reactions": 149, "comments": 38, "reposts": 10, + "format": "document-carousel", "pillar": "trust-debt", "chars": 1180}, + {"date": "2026-06-24", "impressions": 2650, "reactions": 47, "comments": 9, "reposts": 2, + "format": "text-post", "pillar": "modelling", "chars": 760}, +] + + +def median(xs): + s = sorted(xs) + n = len(s) + if not n: + return 0.0 + mid = n // 2 + return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0 + + +def _num(row, key, default=0): + val = row.get(key, default) + if val in (None, ""): + return default + try: + return float(str(val).replace(",", "").strip()) + except ValueError: + return default + + +def prepare(rows: list) -> list: + out = [] + for r in rows: + imp = _num(r, "impressions") + if imp <= 0: + continue + inter = sum(_num(r, f) for f in ("reactions", "comments", "reposts")) + rec = {"er": inter / imp, + "format": str(r.get("format") or "unspecified"), + "pillar": str(r.get("pillar") or "unspecified")} + date = str(r.get("date") or "") + try: + rec["weekday"] = WEEKDAYS[datetime.date.fromisoformat(date[:10]).weekday()] + except (ValueError, IndexError): + rec["weekday"] = "unspecified" + chars = _num(r, "chars", 0) + if chars: + rec["length"] = ("short (<800)" if chars < 800 else + "medium (800-1500)" if chars <= 1500 else "long (>1500)") + else: + rec["length"] = "unspecified" + links = _num(r, "links_in_body", -1) + rec["link_in_body"] = "unspecified" if links < 0 else ("yes" if links else "no") + out.append(rec) + return out + + +def permutation_p(group_ers, other_ers, observed, rng) -> float: + pool = group_ers + other_ers + k = len(group_ers) + extreme = 0 + for _ in range(SHUFFLES): + rng.shuffle(pool) + diff = median(pool[:k]) - median(pool[k:]) + if abs(diff) >= abs(observed): + extreme += 1 + return (extreme + 1) / (SHUFFLES + 1) + + +def mine(rows: list, attributes: list) -> dict: + data = prepare(rows) + if len(data) < MIN_POSTS: + return {"verdict": "INSUFFICIENT_DATA", "exit_code": 3, "posts": len(data), + "floor": MIN_POSTS, + "finding": f"{len(data)} usable posts. Under {MIN_POSTS} there is nothing to test: " + "the between-post variance on LinkedIn swamps any group difference this " + "small a sample could show.", + "instead": "Keep posting on the plan you have and re-run this in six weeks. " + "Changing strategy on eight posts is how people end up rewriting their " + "approach every month and compounding nothing."} + + rng = random.Random(SEED) + candidates = [] + mirrored = [] + for attr in attributes: + values = sorted({d[attr] for d in data if d[attr] != "unspecified"}) + # A two-value attribute is one test, not two: "carousel vs rest" and + # "text vs rest" are the same comparison with the sign flipped. Testing + # both would double-count it in the multiple-comparisons accounting. + if len(values) == 2: + mirrored.append(f"{attr}: only '{values[0]}' tested — '{values[1]}' is the same " + "comparison mirrored") + values = values[:1] + for val in values: + group = [d["er"] for d in data if d[attr] == val] + other = [d["er"] for d in data if d[attr] != val and d[attr] != "unspecified"] + entry = {"attribute": attr, "value": val, + "n_group": len(group), "n_other": len(other)} + if len(group) < MIN_GROUP or len(other) < MIN_GROUP: + entry.update({"verdict": "NOT_TESTED", + "reason": f"needs {MIN_GROUP} in and {MIN_GROUP} out; " + f"has {len(group)} and {len(other)}"}) + candidates.append(entry) + continue + m_g, m_o = median(group), median(other) + observed = m_g - m_o + rel = (m_g - m_o) / m_o if m_o else 0.0 + entry.update({"median_group": round(m_g, 5), "median_other": round(m_o, 5), + "relative_effect": round(rel, 3)}) + if abs(rel) < MIN_RELATIVE_EFFECT: + entry.update({"verdict": "TOO_SMALL", + "reason": f"{rel:+.1%} relative difference is under the " + f"{MIN_RELATIVE_EFFECT:.0%} floor — real or not, it is " + "not a reason to change anything"}) + candidates.append(entry) + continue + p = permutation_p(list(group), list(other), observed, rng) + entry["p_value"] = round(p, 4) + if p < ALPHA: + entry.update({"verdict": "SUPPORTED", + "reason": f"{rel:+.1%} median difference, beat {1 - p:.0%} of " + f"{SHUFFLES} label shuffles"}) + else: + entry.update({"verdict": "NOT_SUPPORTED", + "reason": f"{rel:+.1%} difference, but {p:.0%} of random shuffles " + "produced one as large — this is noise"}) + candidates.append(entry) + + tested = [c for c in candidates if "p_value" in c] + supported = [c for c in candidates if c["verdict"] == "SUPPORTED"] + expected_false = round(len(tested) * ALPHA, 1) + + return { + "verdict": "PATTERNS_FOUND" if supported else "NOTHING_SURVIVED", + "exit_code": 0 if supported else 2, + "posts": len(data), + "method": {"min_group": MIN_GROUP, "min_relative_effect": MIN_RELATIVE_EFFECT, + "alpha": ALPHA, "shuffles": SHUFFLES, "seed": SEED, + "statistic": "difference of medians, permutation test"}, + "candidates_generated": len(candidates), + "candidates_tested": len(tested), + "supported": supported, + "all_candidates": candidates, + "mirrored_candidates_skipped": mirrored, + "independence_note": ( + "Candidates within one attribute are not independent of each other — each group is " + "tested against the rest of the same posts. Read them as one question about that " + "attribute, not as several separate findings."), + "multiple_comparisons_note": ( + f"{len(tested)} candidate(s) reached the test at alpha {ALPHA}. On noise alone you " + f"would expect about {expected_false} to pass. {len(supported)} did. " + + ("Treat these as hypotheses to test deliberately, not as conclusions." + if len(supported) <= max(1, expected_false) + else "More passed than chance predicts, which is mild evidence something real is " + "here — still worth confirming with a deliberate experiment.")), + "next_step": "Feed a supported candidate to experiment_planner.py. A pattern found in " + "past data is a hypothesis; a pattern that survives a planned test is a " + "finding.", + } + + +def render_human(r: dict) -> str: + if r["verdict"] == "INSUFFICIENT_DATA": + return (f"Pattern mining: INSUFFICIENT_DATA\n{'=' * 46}\n{r['finding']}\n\n" + f"Instead: {r['instead']}") + lines = [f"Pattern mining: {r['verdict']} ({r['posts']} posts)", "=" * 62, + f"Method: {r['method']['statistic']}, {r['method']['shuffles']} shuffles, " + f"seed {r['method']['seed']}, alpha {r['method']['alpha']}, " + f"min effect {r['method']['min_relative_effect']:.0%}, " + f"min group {r['method']['min_group']}", ""] + if r["supported"]: + lines.append("SUPPORTED:") + for c in r["supported"]: + lines.append(f" {c['attribute']}={c['value']} n={c['n_group']} vs {c['n_other']} " + f"{c['relative_effect']:+.1%} p={c['p_value']}") + lines.append(f" {c['reason']}") + lines.append("") + lines.append("All candidates:") + for c in r["all_candidates"]: + lines.append(f" [{c['verdict']:<14}] {c['attribute']}={c['value']:<20} {c['reason']}") + if r.get("mirrored_candidates_skipped"): + lines.append("\nSkipped as mirrored:") + for m in r["mirrored_candidates_skipped"]: + lines.append(f" - {m}") + lines += ["", r["independence_note"], "", r["multiple_comparisons_note"], "", r["next_step"]] + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Test candidate LinkedIn patterns against a permutation null " + "(found=0 / nothing-survived=2 / insufficient-data=3).") + ap.add_argument("--input", help="Post export file ('-' for stdin).") + ap.add_argument("--csv", action="store_true", help="Input is CSV rather than JSON.") + ap.add_argument("--attribute", action="append", default=[], + choices=["format", "pillar", "weekday", "length", "link_in_body"], + help="Attribute to test. Repeatable. Default: all five.") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Mine a built-in 16-post sample.") + args = ap.parse_args() + + if args.sample: + rows = SAMPLE + elif args.input: + raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + try: + rows = ([dict(r) for r in csv.DictReader(io.StringIO(raw))] if args.csv + else json.loads(raw)) + except (json.JSONDecodeError, csv.Error) as exc: + print(f"ERROR: could not parse input: {exc}", file=sys.stderr) + return 4 + if isinstance(rows, dict): + rows = rows.get("posts") or rows.get("rows") or [] + else: + ap.error("--input or --sample is required") + + attrs = args.attribute or ["format", "pillar", "weekday", "length", "link_in_body"] + result = mine(rows, attrs) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-analytics/scripts/post_performance_analyzer.py b/marketing/linkedin/skills/linkedin-analytics/scripts/post_performance_analyzer.py new file mode 100644 index 000000000..8f78c8f17 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-analytics/scripts/post_performance_analyzer.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +"""post_performance_analyzer.py — read your own exported post stats and describe them honestly. + +Input is the export LinkedIn gives you for your own content (Analytics → Post +impressions → Export, or the JSON shape below). Nothing is fetched and no other +member's data is involved. + +What it does that a spreadsheet does not: + + - engagement rate per post on a consistent denominator, so posts are comparable + - median and MAD rather than mean and standard deviation, because LinkedIn post + performance is heavy-tailed and one breakout post drags a mean somewhere + useless + - percentile bands and an IQR outlier test, so "this did well" has a threshold + - an explicit refusal to characterise a body of work under 10 posts + +The last one is the point. Ten posts is not enough to conclude anything, and the +tool says so rather than producing a confident-looking table. + +Input JSON: [{"date": "2026-05-04", "title": "...", "impressions": 4210, + "reactions": 88, "comments": 21, "reposts": 4, + "format": "text-post", "pillar": "trust-debt"}, ...] +CSV with the same column names also works. + +Exit codes: + 0 analysed + 2 analysed, but below the 10-post floor — descriptive only, no conclusions + 3 unusable input (no rows, or no impressions column) + +Stdlib only. No network. +""" + +import argparse +import csv +import io +import json +import sys + +MIN_POSTS_FOR_CONCLUSIONS = 10 +REQUIRED = ("impressions",) +INTERACTION_FIELDS = ("reactions", "comments", "reposts") + +SAMPLE = [ + {"date": "2026-05-04", "title": "Onboarding 6 weeks to 4 days", "impressions": 8120, + "reactions": 214, "comments": 63, "reposts": 11, "format": "text-post", "pillar": "trust-debt"}, + {"date": "2026-05-11", "title": "dbt exposures rollout", "impressions": 3050, + "reactions": 61, "comments": 9, "reposts": 2, "format": "text-post", "pillar": "modelling"}, + {"date": "2026-05-18", "title": "Hiring the first AE", "impressions": 2110, + "reactions": 39, "comments": 6, "reposts": 1, "format": "text-post", "pillar": "hiring"}, + {"date": "2026-05-25", "title": "Cost teardown carousel", "impressions": 5400, + "reactions": 132, "comments": 28, "reposts": 9, "format": "document-carousel", + "pillar": "trust-debt"}, + {"date": "2026-06-01", "title": "Why we deleted the kickoff call", "impressions": 4300, + "reactions": 97, "comments": 24, "reposts": 5, "format": "text-post", "pillar": "trust-debt"}, + {"date": "2026-06-08", "title": "Modelling tradeoffs", "impressions": 1780, + "reactions": 28, "comments": 3, "reposts": 0, "format": "text-post", "pillar": "modelling"}, + {"date": "2026-06-15", "title": "Analytics eng job spec", "impressions": 2450, + "reactions": 44, "comments": 11, "reposts": 2, "format": "text-post", "pillar": "hiring"}, + {"date": "2026-06-22", "title": "Queue time carousel", "impressions": 6900, + "reactions": 168, "comments": 41, "reposts": 14, "format": "document-carousel", + "pillar": "trust-debt"}, + {"date": "2026-06-29", "title": "Three dbt anti-patterns", "impressions": 3600, + "reactions": 74, "comments": 15, "reposts": 3, "format": "document-carousel", + "pillar": "modelling"}, + {"date": "2026-07-06", "title": "What I got wrong about capacity", "impressions": 5100, + "reactions": 121, "comments": 33, "reposts": 6, "format": "text-post", "pillar": "trust-debt"}, + {"date": "2026-07-13", "title": "Interview loop for AEs", "impressions": 1950, + "reactions": 31, "comments": 4, "reposts": 1, "format": "text-post", "pillar": "hiring"}, + {"date": "2026-07-20", "title": "Freshness SLAs", "impressions": 2800, + "reactions": 52, "comments": 12, "reposts": 2, "format": "text-post", "pillar": "modelling"}, +] + + +def median(xs): + s = sorted(xs) + n = len(s) + if not n: + return 0.0 + mid = n // 2 + return float(s[mid]) if n % 2 else (s[mid - 1] + s[mid]) / 2.0 + + +def percentile(xs, p): + s = sorted(xs) + if not s: + return 0.0 + k = (len(s) - 1) * (p / 100.0) + lo, hi = int(k), min(int(k) + 1, len(s) - 1) + return float(s[lo] + (s[hi] - s[lo]) * (k - lo)) + + +def load_rows(raw: str, as_csv: bool) -> list: + if as_csv: + return [dict(r) for r in csv.DictReader(io.StringIO(raw))] + data = json.loads(raw) + if isinstance(data, dict): + data = data.get("posts") or data.get("rows") or [] + return data + + +def _num(row, key): + val = row.get(key, 0) + if val in (None, ""): + return 0 + try: + return float(str(val).replace(",", "").strip()) + except ValueError: + return 0 + + +def analyse(rows: list) -> dict: + clean = [] + for r in rows: + imp = _num(r, "impressions") + if imp <= 0: + continue + inter = sum(_num(r, f) for f in INTERACTION_FIELDS) + clean.append({ + "date": str(r.get("date", "")), + "title": str(r.get("title", ""))[:80], + "impressions": int(imp), + "reactions": int(_num(r, "reactions")), + "comments": int(_num(r, "comments")), + "reposts": int(_num(r, "reposts")), + "interactions": int(inter), + "engagement_rate": round(inter / imp, 5), + "comment_share": round(_num(r, "comments") / inter, 3) if inter else 0.0, + "format": str(r.get("format", "") or "unspecified"), + "pillar": str(r.get("pillar", "") or "unspecified"), + }) + + if not clean: + return {"verdict": "UNUSABLE", "exit_code": 3, + "finding": "No rows with a positive impressions value.", + "fix": "Export from LinkedIn Analytics → Post impressions → Export, or supply " + "JSON with an 'impressions' field per post."} + + ers = [p["engagement_rate"] for p in clean] + imps = [p["impressions"] for p in clean] + med = median(ers) + mad = median([abs(e - med) for e in ers]) + q1, q3 = percentile(ers, 25), percentile(ers, 75) + iqr = q3 - q1 + hi_fence, lo_fence = q3 + 1.5 * iqr, q1 - 1.5 * iqr + + for p in clean: + e = p["engagement_rate"] + if e >= hi_fence: + p["band"] = "BREAKOUT" + elif e >= q3: + p["band"] = "STRONG" + elif e >= q1: + p["band"] = "TYPICAL" + elif e > lo_fence: + p["band"] = "WEAK" + else: + p["band"] = "DUD" + + n = len(clean) + below_floor = n < MIN_POSTS_FOR_CONCLUSIONS + result = { + "verdict": "DESCRIPTIVE_ONLY" if below_floor else "ANALYSED", + "exit_code": 2 if below_floor else 0, + "posts_analysed": n, + "floor": MIN_POSTS_FOR_CONCLUSIONS, + "engagement_rate": { + "median": round(med, 5), + "mad": round(mad, 5), + "p10": round(percentile(ers, 10), 5), + "p25": round(q1, 5), "p75": round(q3, 5), + "p90": round(percentile(ers, 90), 5), + "breakout_threshold": round(hi_fence, 5), + }, + "impressions": {"median": round(median(imps)), "p90": round(percentile(imps, 90)), + "total": int(sum(imps))}, + "bands": {b: sum(1 for p in clean if p["band"] == b) + for b in ("BREAKOUT", "STRONG", "TYPICAL", "WEAK", "DUD")}, + "posts": sorted(clean, key=lambda p: -p["engagement_rate"]), + "reading_notes": [ + "Median and MAD, not mean and standard deviation: one breakout post makes a mean " + "describe a distribution nobody's posts belong to.", + "Impressions are not unique people, and LinkedIn's definition has changed over time. " + "Compare posts from the same period, not across a year.", + "Comment share is worth watching separately: comments are the costlier signal for a " + "reader to give, and the one most closely tied to distribution.", + ], + } + if below_floor: + result["warning"] = ( + f"{n} posts is below the {MIN_POSTS_FOR_CONCLUSIONS}-post floor. Everything above is " + "description, not evidence. Do not change strategy on it — the variance between two " + "posts on the same topic is routinely larger than the difference this would 'show'.") + return result + + +def render_human(r: dict) -> str: + if r["verdict"] == "UNUSABLE": + return f"Post analysis: UNUSABLE\n{'=' * 40}\n{r['finding']}\nfix → {r['fix']}" + e = r["engagement_rate"] + lines = [f"Post analysis: {r['verdict']} ({r['posts_analysed']} posts)", "=" * 60] + if "warning" in r: + lines += [f"! {r['warning']}", ""] + lines += [ + f"Engagement rate median {e['median']:.2%} (MAD {e['mad']:.2%})", + f" p10 {e['p10']:.2%} · p25 {e['p25']:.2%} · p75 {e['p75']:.2%} · " + f"p90 {e['p90']:.2%}", + f"Breakout above {e['breakout_threshold']:.2%}", + f"Impressions median {r['impressions']['median']:,} · " + f"p90 {r['impressions']['p90']:,} · total {r['impressions']['total']:,}", + f"Bands " + " · ".join(f"{k} {v}" for k, v in r["bands"].items()), + "", "Posts by engagement rate:"] + for p in r["posts"]: + lines.append(f" {p['band']:<9} {p['engagement_rate']:>7.2%} " + f"{p['impressions']:>7,} imp {p['comments']:>3}c " + f"{p['date']:<11} {p['title']}") + lines.append("\nReading notes:") + for note in r["reading_notes"]: + lines.append(f" - {note}") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Describe your own LinkedIn post export honestly " + "(analysed=0 / below-floor=2 / unusable=3).") + ap.add_argument("--input", help="Post export file ('-' for stdin).") + ap.add_argument("--csv", action="store_true", help="Input is CSV rather than JSON.") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Analyse a built-in 12-post sample.") + ap.add_argument("--print-schema", action="store_true", help="Print the input shape and exit.") + args = ap.parse_args() + + if args.print_schema: + print(json.dumps(SAMPLE[:2], indent=2)) + return 0 + if args.sample: + rows = SAMPLE + elif args.input: + raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + try: + rows = load_rows(raw, args.csv) + except (json.JSONDecodeError, csv.Error) as exc: + print(f"ERROR: could not parse input: {exc}", file=sys.stderr) + return 4 + else: + ap.error("--input or --sample is required (see --print-schema)") + + result = analyse(rows) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-content/SKILL.md b/marketing/linkedin/skills/linkedin-content/SKILL.md new file mode 100644 index 000000000..2b6d88435 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/SKILL.md @@ -0,0 +1,99 @@ +--- +name: linkedin-content +description: Use when someone wants to write, edit, or lint a LinkedIn post — a story, how-to, opinion piece, carousel script, video script, or poll — or wants an article, talk, or transcript repurposed into posts. Triggers on "write a LinkedIn post", "is this hook any good", "review my post", "turn this into LinkedIn posts", "carousel", "what format should this be". Lints posts 0-100 on mechanics, hook, integrity, and accessibility; picks the format the material actually supports; and splits long sources into standalone units with a reuse ledger. +license: MIT +metadata: + version: 1.0.0 + author: Alireza Rezvani + category: marketing + updated: 2026-08-25 +--- + +# LinkedIn Content — format, draft, lint + +The post is not the deliverable; the specific thing only this person can say is. The scripts +handle format choice, mechanical faults, and de-duplication. The interesting part — what +actually happened and what it cost — comes from the user, and cannot be generated. + +## Workflow + +**1. Check the brief exists.** If there is no positioning brief, offer `linkedin-strategy` +first as a question. Posts without pillars are noise. Never chain silently. + +**2. Pick the format from the material, not from fashion.** + +```bash +python3 scripts/format_picker.py --goal authority --material data --material tutorial \ + --minutes 120 --output human +``` + +Exit 0 recommends / 2 asks when the top two are within a point (tie-break on which one they +would enjoy making — the one they repeat beats the one that scores higher once) / 3 no fit, +go get material. It refuses video with no camera and no footage, and a poll with no real +decision behind it. + +**3. Draft.** Interview for specifics before writing a line: the number, the mistake, the +sentence someone said. Then write to the fold — **the first ~140 characters are the whole +post for most readers**, and a sentence must complete inside them. + +**4. Lint before it ships.** + +```bash +python3 scripts/post_linter.py --input draft.md --has-image --output human +``` + +Exit 0 SHIP / 2 REVISE (or any blocking finding) / 3 REWRITE. Blocking findings have named +consequences: over the 3,000-character cap; engagement bait, named as demoted content in the +Professional Community Policies; Unicode pseudo-bold, which screen readers announce as +mathematical symbols and search does not index as words. Two passes is normal; the stop +condition is exit 0 or a stated decision to accept a warning. + +**5. Repurposing.** Split a source and keep the ledger — it prevents the specific recurring +failure of the same idea going out three times over eight months. Commit it alongside the +source; it is project state, not a cache. Every unit is source material, not a post: add the +sentence only the author can write — what it cost, or what they would do differently. + +```bash +python3 scripts/repurpose_splitter.py --input talk.md --ledger .linkedin-ledger.json --output human +python3 scripts/repurpose_splitter.py --input talk.md --ledger .linkedin-ledger.json --record 2 --posted-on 2026-08-25 +``` + +## Rules + +- **Never fabricate a number, a client, a result, or a quote.** Not one, not as a placeholder. +- **Write to the mobile fold.** A sentence completes before character 140. +- **Links in the first comment**, and say so in the post. +- **No Unicode pseudo-bold, ever.** It is an accessibility failure, not a style choice. +- **Alt text on every image; captions on every video.** LinkedIn does not add them for you. +- **No engagement bait.** Ask the question the post actually earned. +- **The author reads every line before it ships.** They are the author of record. + +## Scripts + +| Script | Role | +|---|---| +| [`scripts/post_linter.py`](scripts/post_linter.py) | 0-100 across mechanics / hook / integrity / accessibility; blocking findings for the cap, bait, and pseudo-bold. | +| [`scripts/format_picker.py`](scripts/format_picker.py) | Ranks nine native formats against goal, material, and minutes; refuses camera-less video and decision-less polls. | +| [`scripts/repurpose_splitter.py`](scripts/repurpose_splitter.py) | Splits a source into standalone units, scores them, and skips anything already in the reuse ledger. | + +## References and assets + +- [`references/hook_and_fold_mechanics.md`](references/hook_and_fold_mechanics.md) — the first 140 characters, and the openers to delete on sight (7 sources) +- [`references/post_formats_canon.md`](references/post_formats_canon.md) — what each native format is good at, and the carousel trap (7 sources) +- [`references/repurposing_discipline.md`](references/repurposing_discipline.md) — standalone units, the reuse ledger, source types and their risks (7 sources) +- [`references/accessibility_and_inclusion.md`](references/accessibility_and_inclusion.md) — pseudo-bold, alt text, captions, and why they are blocking (7 sources) + +- [`assets/post_templates.md`](assets/post_templates.md) — five worked post shapes with the hook already doing its job +- [`assets/example_post.md`](assets/example_post.md) — a post that passes the linter, annotated + +## Distinct from + +- **`linkedin-strategy`** — decides what the posts are about. This writes them. +- **`linkedin-engagement`** — comments and DMs. A comment is a different craft with a + different budget; it lives there. +- **`marketing-skill/copywriting` / `content-humanizer`** — general copy and de-AI passes. + Reach for those for tone; this one owns LinkedIn's mechanics and its accessibility floor. + +--- + +**Version:** 1.0.0 diff --git a/marketing/linkedin/skills/linkedin-content/assets/example_post.md b/marketing/linkedin/skills/linkedin-content/assets/example_post.md new file mode 100644 index 000000000..ae137658f --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/assets/example_post.md @@ -0,0 +1,58 @@ +# Worked Example — a post that passes the linter + +The post below scores 98/100 in `post_linter.py` with one INFO finding (it sits below the +1,300-2,500 character band, which is not a defect). Annotations follow. + +--- + +Our onboarding took 6 weeks. We got it to 4 days without hiring anyone. + +The bottleneck was not the product. It was that three different teams each owned +one step and none of them owned the handoff. + +What we changed: + +1. One named owner for the whole path, not per step. Every stall now has someone + whose week it ruins. +2. We deleted the "kickoff call" and replaced it with a 4-question form. 80% of + accounts never needed the call. +3. We stopped treating the CRM stage as the source of truth and started measuring + the customer's first real use. + +The part I got wrong: I assumed the delay was engineering capacity. It was +handoffs. We spent five weeks building automation for the wrong step before +anyone measured where the time actually went. + +If you are staring at a slow onboarding number, measure the wait between steps +before you optimise any single step. That is where ours was hiding. + +What did the handoff cost you the last time you measured it? + +--- + +## Why it passes + +**Hook (first 140 characters).** Two complete sentences land inside the mobile fold, both +carrying numbers, and the second names a constraint people assume is binding — "without +hiring anyone". A reader decides on those two sentences alone. + +**Specificity throughout.** 6 weeks, 4 days, 80%, five wasted weeks. Every number is one the +author could be challenged on, which is exactly why they carry weight. + +**The admission.** "The part I got wrong" is the load-bearing paragraph. It is expensive to +write, which is why it reads as true, and it is the paragraph practitioners reply to. + +**Structure.** Seven blocks, none longer than four lines. Numbered list where the content is +a sequence. No Unicode pseudo-bold, no emoji, no hashtags — none of which are required. + +**The close.** A real question with a specific answer available only to someone who has +measured the same thing. Not "thoughts?". + +**No link in the body.** If there were a write-up, it goes in the first comment with "link +in the comments" in the post. + +## Run it yourself + +```bash +python3 ../scripts/post_linter.py --sample --output human +``` diff --git a/marketing/linkedin/skills/linkedin-content/assets/post_templates.md b/marketing/linkedin/skills/linkedin-content/assets/post_templates.md new file mode 100644 index 000000000..2619d6641 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/assets/post_templates.md @@ -0,0 +1,134 @@ +# Post Shapes + +Five structures, each with the hook already doing its job. They are scaffolding, not +templates to fill and ship — the specific detail is the post, and it has to be yours. + +Every one of these assumes: a sentence completes inside the first ~140 characters, links go +in the first comment, no Unicode pseudo-bold, and a real question at the end. + +--- + +## 1. The measurement that changed our mind + +Best for: `trust`, `authority`. Format: text post or carousel. + +``` +[The number nobody believed.] +[The instinct everyone had, named fairly.] + +What we actually measured: +[The two figures that reframed it.] + +[What we changed - three items, each with a consequence.] + +The part I got wrong: +[The thing you spent time on before measuring.] + +[A question aimed at someone facing the same measurement.] +``` + +Why it works: the hook is a number and a reversal. The "part I got wrong" is a costly +signal — admitting error is expensive, which is why it reads as true. + +--- + +## 2. The decision, with the cost attached + +Best for: `thought-leadership`. Format: text post. + +``` +[We stopped doing X.] [The consequence, quantified.] + +The argument for keeping it was [the strongest version of the other side]. +It was a real argument. Here is why it lost: + +[Two or three sentences of actual reasoning.] + +What it cost us: [the real downside, named]. + +[The question: where does this break for you?] +``` + +Why it works: steelmanning the other side before disagreeing is the rarest move in a +professional feed, and the "what it cost" line inoculates against the obvious objection. + +--- + +## 3. The teardown + +Best for: `authority`, `consulting`. Format: carousel or long text post. + +``` +[One named artifact - with permission, or anonymised.] +[What it gets right, in one sentence. Say this first and mean it.] + +Three things I would change: + +1. [Change] - because [mechanism, not preference] +2. [Change] - because [mechanism] +3. [Change] - because [mechanism] + +The one I am least sure about: [the honest uncertainty]. + +[Question to the person who has done this differently.] +``` + +Why it works: specificity plus a stated uncertainty. The uncertainty is what invites the +comment worth having. + +--- + +## 4. The field note + +Best for: any pillar, and the format that keeps a cadence alive in a bad week. Format: text +post, ~500-900 characters. + +``` +[What you did this week, concretely.] + +[The thing that surprised you.] + +[What you will do differently next week because of it.] + +[Optional: a question only someone doing the same work could answer.] +``` + +Why it works: it costs 15 minutes, it is impossible to fake, and it is the format that +compounds because you can always write one. + +--- + +## 5. The transition post + +Best for: `career-change`. Format: text post. + +``` +[The specific thing you did in the new field this month.] +[Not "I am excited to announce" - the thing itself.] + +What I brought from [old field] that turned out to matter: +[One transferable capability, evidenced.] + +What I had to learn from zero: +[One honest gap, named without apology.] + +[Where you are heading, stated as a direction rather than a request.] + +[A question for people already doing the thing.] +``` + +Why it works: it demonstrates the transition rather than announcing it. Announcements ask +for permission; evidence does not. See `linkedin-strategy/references/objective_to_pillars.md` +on why career-change posts should be aimed at the target field, not at current colleagues. + +--- + +## Openers to delete on sight + +"I'm excited to announce" · "I'm thrilled" · "I'm humbled" · "In today's fast-paced world" · +"Quick thought:" · any first eight words that would fit any post about anything. + +## Closers that are engagement bait, not questions + +"Thoughts?" · "Agree?" · "Comment X below and I'll send you the guide" · "Like if you agree" · +"Tag someone who needs this". `post_linter.py` treats these as blocking. diff --git a/marketing/linkedin/skills/linkedin-content/references/accessibility_and_inclusion.md b/marketing/linkedin/skills/linkedin-content/references/accessibility_and_inclusion.md new file mode 100644 index 000000000..bea8eb185 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/references/accessibility_and_inclusion.md @@ -0,0 +1,126 @@ +# Accessibility and Inclusion — the part of LinkedIn craft nobody audits + +LinkedIn is a professional network, which means a meaningful fraction of your +audience uses assistive technology at work — screen readers, captions, +magnification, high-contrast modes. It also means the accessibility failures in +LinkedIn posts are unusually visible, because they are almost all self-inflicted +by formatting tricks people adopt for reach. + +`post_linter.py` treats two of these as blocking findings. This document is why. + +--- + +## 1. Unicode pseudo-bold is the big one — blocking + +LinkedIn does not support rich text in posts, so people paste text through "bold +text generators". Those tools do not bold anything. They substitute the Latin +letters for characters from the **Mathematical Alphanumeric Symbols** block +(U+1D400-U+1D7FF), which are visually similar and semantically unrelated. + +What actually happens: + +- **Screen readers** announce them individually as mathematical symbols — + "mathematical sans-serif bold small a, mathematical sans-serif bold small b" — + or skip them entirely, depending on the reader and its verbosity settings. + A "bolded" heading becomes noise or silence. +- **Search** does not index them as the words they resemble. Your post about + 𝗸𝘂𝗯𝗲𝗿𝗻𝗲𝘁𝗲𝘀 does not match a search for kubernetes. +- **Copy-paste** into any system with real text handling produces mojibake. +- **Translation** fails. + +There is no version of this that is worth it. Emphasis on LinkedIn comes from +line breaks, word order, and putting the important thing first — the same tools +every writer had before bold existed. + +The same applies to fullwidth characters (A-z) and enclosed alphanumerics. + +## 2. Alt text on images — LinkedIn supports it and will not write it + +LinkedIn offers an alt-text field on image uploads. It is not filled in for you +and it is easy to skip. + +Writing it well takes one sentence and one rule: **describe what the image +communicates, not what it is.** "Chart" is useless. "Line chart: median +onboarding time falling from 41 days in March to 4 days in July" is the whole +content of the image, delivered to someone who cannot see it — and, incidentally, +to anyone whose image failed to load. + +For a document carousel, upload a real PDF with selectable text rather than +exported images. The text layer is what makes the slides readable to assistive +technology at all. + +## 3. Video captions — the floor, and also just correct + +Most feed video is watched sound-off, so captions serve everyone. LinkedIn +provides auto-captions with an editing step. **The editing step is not optional**: +auto-captioning mangles exactly the domain vocabulary your post is about, and an +uncorrected caption track is worse than none because it looks like a caption +track. + +WCAG 2.2 SC 1.2.2 (Captions, Prerecorded) is the standard, and for a professional +audience it is also the polite minimum. + +## 4. Emoji load — warning, not blocking + +Emoji are read aloud by name. A bullet list built from 🔥 emoji becomes "fire, +fire, fire". A handful is fine and can even help structure. Fifteen makes a post +tiring to hear. + +Where emoji do useful work: as list markers (one per line, consistently), or as a +segment separator in a headline. Where they do not: decoration, emphasis, or +replacing words. + +## 5. ALL-CAPS lines — warning + +Some screen readers spell out all-caps words letter by letter, treating them as +initialisms. A full line of capitals becomes an alphabet recital. Sentence case, +with the emphasis carried by the words. + +## 6. Colour and contrast in carousels and images + +If you make slides: + +- Do not encode meaning in colour alone (WCAG 1.4.1). If the red bar is the bad + one, label it. +- Body text on a slide needs 4.5:1 contrast against its background (WCAG 1.4.3); + large text needs 3:1. +- Slide text should be large enough to read on a phone at feed size. If you have + to zoom to read your own slide on your own phone, it is too small. + +## 7. Plain language is an accessibility feature + +Not only for non-native readers, though that is a large part of a global +professional audience. Dense sentences with three subordinate clauses are harder +for everyone, and much harder for anyone reading through a screen reader with no +ability to skim back. + +Practical version: short sentences, one idea per paragraph, expand an acronym the +first time. This is the same advice as good writing, which is convenient. + +## 8. What this costs + +About four minutes per post: write the alt text, check the captions, do not paste +through a bold generator. It is the cheapest quality signal available and almost +nobody does it, which means it is also differentiating. + +--- + +## Sources + +1. W3C. **Web Content Accessibility Guidelines (WCAG) 2.2** — SC 1.1.1 (Non-text + Content), 1.2.2 (Captions, Prerecorded), 1.4.1 (Use of Color), 1.4.3 + (Contrast Minimum). +2. Unicode Consortium. **Unicode Standard, Chapter 22 / Mathematical Alphanumeric + Symbols (U+1D400-U+1D7FF)** — what these characters are actually for, and the + explicit note that they are not styled Latin letters. +3. WebAIM. **Screen Reader User Survey** (recurring) — how screen-reader users + actually navigate, and the cost of non-semantic text substitutes. +4. LinkedIn Help. **"Add alternative text to images"** and LinkedIn's video + captioning documentation — what the platform supports. +5. LinkedIn. **Accessibility statement / LinkedIn Accessibility** — the + platform's own commitments and the assistive-technology surfaces it supports. +6. Nielsen Norman Group. **"Plain Language Is for Everyone, Even Experts"** — + comprehension gains from plain language across expertise levels. +7. Deque / axe accessibility documentation on **text alternatives and meaningful + sequence** — practical guidance for writing alt text that carries the content + rather than naming the object. diff --git a/marketing/linkedin/skills/linkedin-content/references/hook_and_fold_mechanics.md b/marketing/linkedin/skills/linkedin-content/references/hook_and_fold_mechanics.md new file mode 100644 index 000000000..ad1020b8f --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/references/hook_and_fold_mechanics.md @@ -0,0 +1,126 @@ +# Hook and Fold Mechanics — the first 140 characters decide everything else + +LinkedIn truncates a post in the feed. On mobile that happens around 140 +characters, on desktop around 210, and the reader's decision to press "…see more" +is made entirely on what is above that line. Everything you wrote below it is +conditional on those two sentences. + +This is not a copywriting flourish. It is the single structural constraint the +platform imposes on text, and most posts ignore it. + +--- + +## The numbers + +| Limit | Value | Confidence | +|---|---|---| +| Post hard cap | 3,000 characters | 🟡 third-party documented, stable | +| Mobile fold ("…see more") | ~140 characters | 🟡 varies with viewport and locale | +| Desktop fold | ~210 characters | 🟡 | +| Highest median engagement band | ~1,300-2,500 characters | 🟡 third-party studies | +| Comment character cap | 1,250 | 🟡 | + +Mobile is the binding constraint. Write to 140 and desktop takes care of itself. + +**On the engagement band:** it is a correlation in third-party data, not a rule. +Long posts are longer because people had more to say, and a 400-character post +with something specific in it beats a padded 1,800-character one every time. +`post_linter.py` reports the band as INFO, never as a defect. + +## What a hook has to do + +Not "be catchy". Three concrete jobs: + +1. **Complete a thought inside the fold.** A truncated fragment gives the reader + nothing to decide on. `post_linter.py` flags a post where no sentence ends + before character 140. +2. **Create a specific gap.** Not a vague tease — a gap the reader can feel the + shape of. "Our onboarding took six weeks. We got it to four days without + hiring anyone." The gap is *how*, and it is specific enough to be worth the + click. +3. **Signal who it is for.** A reader outside your audience should be able to + skip cleanly. Trying to hook everyone is how a post hooks nobody. + +## Hook shapes that work, and why + +| Shape | Example opening | Why it works | +|---|---|---| +| **Number + reversal** | "Our onboarding took 6 weeks. We got it to 4 days without hiring anyone." | Concrete, and the reversal names the constraint people assume is binding | +| **The mistake** | "I spent five weeks automating the wrong step." | Costly-signal: admitting error is expensive, so it reads as true | +| **The measurement** | "Work-in-progress time was 6 days. Wait time between owners was 35." | The number does the work; no adjective required | +| **The sentence someone said** | "'Nobody believes the dashboard.' That was the actual problem." | Quoted speech is concrete and carries a voice | +| **The refusal** | "We deleted the kickoff call. 80% of accounts never needed it." | A decision with a consequence attached | + +## Openers to delete on sight + +`post_linter.py` flags these because they are the most-scrolled-past +constructions on the platform: + +- "I'm excited to announce…" / "I'm thrilled…" / "I'm humbled…" +- "In today's fast-paced world…" +- "Quick thought:" +- Any opener whose first eight words would fit any post about anything. + +The excitement openers have a specific problem: they put *your* feeling first and +the reader's interest second, in the two sentences where you can least afford it. +Announce the thing, then say why it mattered. + +## Formatting inside the fold and below it + +- **Line breaks are structure, not decoration.** Three to four lines per block. +- **Do not write in one-line paragraphs throughout.** The "broetry" cadence — + every sentence its own paragraph — reads as formatted for an algorithm rather + than for a person, and the linter flags six or more consecutive one-liners. +- **No Unicode pseudo-bold.** The "bold text generators" produce Mathematical + Alphanumeric Symbols. Screen readers announce them character by character as + mathematical symbols, and LinkedIn's search does not index them as words. This + is a blocking finding in the linter, not a style preference — see + `accessibility_and_inclusion.md`. +- **Links go in the first comment.** Say "link in the comments" in the post. + +## The close + +The end of a post is where a comment either happens or does not. Two failure +modes: + +- **No invitation at all.** The post is complete, correct, and closed; the only + available response is agreement. +- **A fake invitation.** "Thoughts?" and "Agree?" are engagement bait with a + question mark. `post_linter.py` treats bait as blocking, and LinkedIn's + Professional Community Policies name it as demoted content. + +What works is a real question you would want answered, narrow enough that a +specific person has a specific answer: "What did the handoff cost you the last +time you measured it?" + +## Editing pass + +Three passes, in this order: + +1. **Cut the first paragraph.** It is usually the throat-clearing. Check whether + the post starts better at paragraph two — it does more often than not. +2. **Read the first 140 characters alone.** Would you press "see more"? +3. **Read it aloud.** Every sentence you stumble on is a sentence a reader + stumbles on. This catches more than any linter. + +--- + +## Sources + +1. Nielsen Norman Group. **F-Shaped Pattern for Reading Web Content** (2006) and + **"How People Read Online"** — scanning behaviour and the weight of the first + visible lines. +2. Nielsen Norman Group. **"Microcontent: How to Write Headlines, Page Titles, + and Subject Lines"** — front-loading for any truncated string. +3. Loewenstein, G. **"The Psychology of Curiosity: A Review and Reinterpretation."** + *Psychological Bulletin*, 1994 — the information-gap theory that explains why a + *specific* gap works and a vague tease does not. +4. Heath, C. & Heath, D. **Made to Stick** — concreteness, unexpectedness, and + the curse of knowledge. +5. Zinsser, W. **On Writing Well** — cutting every word that does no work; the + "cut the first paragraph" pass comes straight from here. +6. LinkedIn Engineering. **"Understanding feed dwell time to improve LinkedIn + feed ranking."** — why holding attention past the fold is a measured + objective, and why padding to game it is not. +7. LinkedIn. **Professional Community Policies** — engagement bait as named, + demoted content. diff --git a/marketing/linkedin/skills/linkedin-content/references/post_formats_canon.md b/marketing/linkedin/skills/linkedin-content/references/post_formats_canon.md new file mode 100644 index 000000000..1a7cafe49 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/references/post_formats_canon.md @@ -0,0 +1,139 @@ +# Post Formats Canon — what each native format is good at + +Format is usually chosen by fashion. "Carousels are working right now" is the +single most common reason a person makes a carousel, and it is why most carousels +are a text post cut into slides. + +The useful question is not which format performs best in general. It is which +format the material you actually have can support, in the time you actually have. + +--- + +## The nine native formats + +### Plain text post +**Good at:** stories, opinions, single ideas, anything where the words are the +point. The default, and it should be. +**Cost:** ~25 minutes including revision. +**Constraint:** one idea. If it needs two, it is two posts. + +### Document post (PDF carousel) +**Good at:** step sequences, comparisons, data with structure, anything +inherently visual. +**Cost:** ~90 minutes. +**Constraints:** every slide must survive alone — most readers swipe two and +leave, so the payload cannot live on slide nine. Upload a real PDF with +selectable text rather than exported images: text is accessible, indexed, and +readable when a slide is zoomed. Slide one is the hook, and it is subject to the +same test as a text hook. + +### Native video +**Good at:** demonstration, personality, anything where seeing it beats reading it. +**Cost:** ~120 minutes for anything watchable. +**Constraints:** captions are mandatory — most feed viewing is sound-off, and +captions are also the accessibility floor. Say the point in the first five +seconds; the fold applies to video too, it is just measured in seconds. + +### Single image + text +**Good at:** one chart, one photo, one artifact. Announcements. +**Cost:** ~30 minutes. +**Constraint:** write alt text. LinkedIn supports it and does not generate it for +you. A chart with no alt text excludes readers and says nothing to anyone whose +image fails to load. + +### Poll +**Good at:** settling a real question you will report back on. +**Cost:** ~10 minutes. +**Constraint:** `format_picker.py` refuses a poll without a declared decision. +A poll you do not follow up on is a reach trick with a two-week half-life, and +readers have learned to recognise it. The follow-up post — "here is what 400 of +you said, and here is what we changed" — is the actual content. + +### Long-form article +**Good at:** the durable artifact. A link you will still send someone in two +years. +**Cost:** ~180 minutes. +**Constraint:** articles reach far fewer people than posts. Write one when the +artifact matters more than this week's impressions, and then mine it for posts. + +### Newsletter issue +**Good at:** a returning readership. +**Cost:** ~150 minutes, plus a standing promise. See `newsletter_playbook.md`. + +### Substantive comment on someone else's post +**Good at:** visibility from a standing start. The most under-rated format on the +platform and the cheapest by an order of magnitude. +**Cost:** ~6 minutes. +**Constraint:** it has to add something the original missed. Agreement is not a +comment. + +### Repost with your own take +**Good at:** entering a conversation someone else started, with a position. +**Cost:** ~15 minutes. +**Constraint:** your take must be longer than "this". A bare repost spends your +credibility on someone else's idea and returns nothing. + +--- + +## Choosing + +`format_picker.py` scores goal fit against material fit and refuses what the time +budget cannot pay for. Two refusals are hard: + +- **Video with no camera and no footage.** Not a judgement about your face; a + refusal to plan work that will not happen. +- **A poll with no real decision behind it.** + +When two formats score within a point, the tool asks rather than picking. The +tie-breaker is which one you would actually enjoy making, because the one you +repeat beats the one that scores higher once. + +## The carousel trap + +Carousels reliably out-perform on engagement rate in third-party data, which +produces a predictable failure: people convert text posts into carousels for the +reach and end up with ten slides carrying two slides of content. + +The test: **could a reader get the value from slide one plus the caption?** If +yes, it is a text post and the extra nine slides are cost. If no — if the +sequence itself is the content — it is a carousel. + +## Video captions are not optional + +Beyond accessibility: the majority of feed video is watched sound-off. An +uncaptioned video is a silent film with no intertitles. LinkedIn offers +auto-captions with an edit step; the edit step is not optional either, because +auto-captions mangle exactly the domain terms your post is about. + +## Cross-format sequencing + +A single body of work supports a sequence, and the sequence out-performs any one +piece: + +1. **Text post** with the specific finding (cheap, tests appetite) +2. **Carousel** with the full method, two weeks later, if the post landed +3. **Article or newsletter issue** as the durable artifact +4. **Comments** on other people's related posts throughout, linking nothing + +Run the source through `repurpose_splitter.py` with a ledger so the same unit +does not go out twice under two different formats. + +--- + +## Sources + +1. Tufte, E. **The Visual Display of Quantitative Information** — data-ink ratio, + the direct argument against a carousel slide that carries one sentence. +2. Tufte, E. **The Cognitive Style of PowerPoint** — the specific failure of + slide sequences that fragment an argument into bullet residue. +3. Nielsen Norman Group. **"How People Read Online"** and mobile reading + research — scanning and the cost of sequential reveal. +4. W3C. **Web Content Accessibility Guidelines (WCAG) 2.2**, SC 1.1.1 (Non-text + Content) and 1.2.2 (Captions, Prerecorded) — the floor for images and video. +5. LinkedIn Help. **"Add alternative text to images"** and LinkedIn's video + caption documentation — what the platform supports and what it does not do + for you. +6. van der Blom, R. **Algorithm Insights** (annual) — 🟡 comparative format + performance in public-post samples; directionally useful, not a specification. +7. Kleon, A. **Show Your Work!** — process content as the material most formats + are actually best at carrying. diff --git a/marketing/linkedin/skills/linkedin-content/references/repurposing_discipline.md b/marketing/linkedin/skills/linkedin-content/references/repurposing_discipline.md new file mode 100644 index 000000000..430a49e6d --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/references/repurposing_discipline.md @@ -0,0 +1,124 @@ +# Repurposing Discipline — one idea, many surfaces, no duplicates + +Repurposing is the highest-leverage move available to anyone who already produces +work: a talk, an article, a README, an internal write-up, a customer call you +took notes on. It is also the fastest route to a feed that feels like a rerun. + +The discipline has two halves. One is mechanical — splitting a source into units +that can stand alone. The other is editorial, and it is the half that gets +skipped: **the first-person sentence only you can write.** + +--- + +## What makes a unit standalone + +`repurpose_splitter.py` scores four things, each 25 points: + +1. **Length in range** (240-2,400 characters). Under 240 there is not room for a + claim and its evidence; over 2,400 it wants splitting again. +2. **No dangling reference.** A unit that opens with "This meant that…" or "As we + saw above" refers to something the reader never saw. This is the single most + common defect in repurposed material and it is invisible to the author, who + has read the source. +3. **Evidence present.** A number, a duration, a measurable detail. A unit + carrying no evidence can still work as an opinion post, but it is a different + kind of post and should be routed as one. +4. **Three or more substantive sentences.** Below that it is a note. + +A unit failing 2 or 4 is disqualified regardless of its total score. A dangling +opener fails in the feed no matter how good the evidence beneath it is. + +## The reuse ledger + +This is the part that matters and the part no other repurposing workflow has. + +Repurposing fails in one specific way: the same idea goes out three times over +eight months, and the audience notices before the author does. It happens because +the source is long, the good units are memorable, and eight months is longer than +anyone's memory of what they posted. + +`--ledger` stores a normalised content hash of every unit marked as posted, with +the date. Used units are skipped by default and shown with their date on request. +It is a small file and it prevents a specific, embarrassing, recurring failure. + +```bash +# Split, see what is available +python3 scripts/repurpose_splitter.py --input talk-transcript.md --ledger .linkedin-ledger.json + +# After publishing unit 2, record it +python3 scripts/repurpose_splitter.py --input talk-transcript.md \ + --ledger .linkedin-ledger.json --record 2 --posted-on 2026-08-25 +``` + +Commit the ledger alongside the source if the source lives in a repo. It is +project state, not a cache. + +## What you always have to add + +Every unit the splitter produces is **source material, not a post**. The tool +will not write the missing part and should not: it is the only genuinely new +thing in a repurposed post. + +The missing part is one of three sentences: + +- **What it cost.** "We spent five weeks automating the wrong step." +- **What you assumed.** "I thought the delay was engineering capacity." +- **What you would do differently.** "I would instrument the handoffs first now." + +A repurposed post without one of these reads as a summary of something else, +because that is what it is. + +## Source types and what they yield + +| Source | Typical yield | The specific risk | +|---|---|---| +| Conference talk transcript | 4-8 units | Spoken asides do not survive as text; the connective tissue is all dangling references | +| Long article / essay | 3-6 units | Sections written to build on each other rarely stand alone | +| Technical README or docs | 2-4 units | Instructional voice; needs the "why we needed this" frame added | +| Internal post-mortem | 2-5 units | **Consent and confidentiality first.** Anonymise, or get sign-off, or do not | +| Customer conversation notes | 1-3 units | Never quotable without permission. The pattern is publishable; the customer is not | +| Podcast appearance | 3-6 units | You do not own the recording; check before quoting at length | + +## Cross-platform, not just within LinkedIn + +The same source usually supports a LinkedIn post, a longer piece somewhere you +own, and a talk proposal. Sequence matters: + +**LinkedIn first when** you want to test whether anyone cares before investing the +long-form time. The post is cheap and the response is informative. + +**Owned platform first when** the artifact is the point and LinkedIn is +distribution. Publish there, then post the strongest unit on LinkedIn with the +link in the first comment. + +What does not work is publishing the identical text in both places on the same +day. LinkedIn readers who follow you elsewhere see a duplicate, and the +LinkedIn version carries the link penalty for no gain. + +## Frequency + +A single strong source can carry a month of posting. It should not carry a +quarter. The signal that you have over-mined a source is that the units start +needing more setup than payload — that is the ledger telling you to go do +something new and write about that instead. + +--- + +## Sources + +1. Kleon, A. **Show Your Work!** — process as publishable material, and the case + for surfacing the same work repeatedly in different forms. +2. Handley, A. **Everybody Writes** — repurposing as an editorial discipline + rather than a content-volume tactic. +3. Vaynerchuk, G. **Jab, Jab, Jab, Right Hook** — platform-native adaptation; + the same idea has to be re-formed, not re-pasted. +4. Nielsen Norman Group. **"How People Read Online"** — why a unit that assumes + prior context fails for a scanning reader who has none. +5. Google Search Central. **Duplicate content guidance** — the mechanics of + cross-posting identical text, and why canonical placement matters for anything + you also own. +6. Ebbinghaus, H. **Über das Gedächtnis** (1885), forgetting curve — the + empirical case for deliberate repetition, and the reason it must be spaced + and varied rather than repeated verbatim. +7. LinkedIn. **Professional Community Policies** — repetitive posting of the same + content is named as spam behaviour; the ledger is how you stay clear of it. diff --git a/marketing/linkedin/skills/linkedin-content/scripts/format_picker.py b/marketing/linkedin/skills/linkedin-content/scripts/format_picker.py new file mode 100644 index 000000000..fbe444f21 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/scripts/format_picker.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""format_picker.py — pick the LinkedIn post format the material actually supports. + +Format is usually chosen by fashion ("carousels are working right now") rather than +by what the person has to say. This scores the nine native formats against three +inputs you actually control — the goal, the raw material in hand, and the minutes +available — and returns a ranked shortlist with the constraint each format carries. + +It refuses two common mistakes outright: + - a poll with no real decision behind it (a reach trick with a two-week half-life + and nothing to say afterwards) + - video when you have said you will not appear on camera and have no footage + +Exit codes: + 0 a format is recommended + 2 the top two are within one point — ask which, do not guess + 3 no format fits the declared material; go get material first + +Stdlib only. No network. Deterministic. +""" + +import argparse +import json +import sys + +GOALS = ["reach", "authority", "leads", "recruiting", "community", "career-change"] +MATERIALS = ["story", "data", "opinion", "tutorial", "announcement", + "transcript", "visual", "question", "curation"] + +# fit[format][goal] and fit[format][material]: 0-3 +FORMATS = { + "text-post": { + "label": "Plain text post", + "goal": {"reach": 3, "authority": 3, "leads": 2, "recruiting": 2, + "community": 3, "career-change": 3}, + "material": {"story": 3, "data": 2, "opinion": 3, "tutorial": 2, + "announcement": 2, "transcript": 1, "visual": 0, + "question": 3, "curation": 1}, + "minutes": 25, + "constraint": "One idea. If it needs two, it is two posts.", + }, + "document-carousel": { + "label": "Document post (PDF carousel)", + "goal": {"reach": 3, "authority": 3, "leads": 2, "recruiting": 1, + "community": 2, "career-change": 2}, + "material": {"story": 1, "data": 3, "opinion": 1, "tutorial": 3, + "announcement": 0, "transcript": 1, "visual": 3, + "question": 0, "curation": 3}, + "minutes": 90, + "constraint": "Every slide must survive alone — most readers swipe two and leave. " + "Upload a real PDF with selectable text, not exported images.", + }, + "native-video": { + "label": "Native video", + "goal": {"reach": 3, "authority": 2, "leads": 2, "recruiting": 3, + "community": 2, "career-change": 2}, + "material": {"story": 3, "data": 1, "opinion": 2, "tutorial": 3, + "announcement": 2, "transcript": 3, "visual": 3, + "question": 1, "curation": 0}, + "minutes": 120, + "constraint": "Captions are mandatory — most viewing is sound-off, and captions are " + "also the accessibility floor. Say the point in the first five seconds.", + }, + "image-post": { + "label": "Single image + text", + "goal": {"reach": 2, "authority": 2, "leads": 1, "recruiting": 2, + "community": 2, "career-change": 2}, + "material": {"story": 2, "data": 3, "opinion": 1, "tutorial": 1, + "announcement": 3, "transcript": 0, "visual": 3, + "question": 1, "curation": 1}, + "minutes": 30, + "constraint": "Write alt text. A chart with no alt text excludes readers and says nothing " + "to anyone who cannot load it.", + }, + "poll": { + "label": "Poll", + "goal": {"reach": 2, "authority": 1, "leads": 1, "recruiting": 1, + "community": 3, "career-change": 1}, + "material": {"story": 0, "data": 1, "opinion": 1, "tutorial": 0, + "announcement": 0, "transcript": 0, "visual": 0, + "question": 3, "curation": 0}, + "minutes": 10, + "constraint": "Only if you will publish what the answers changed. A poll you do not follow " + "up on is a reach trick, and readers have learned to spot it.", + }, + "article": { + "label": "Long-form article", + "goal": {"reach": 1, "authority": 3, "leads": 2, "recruiting": 1, + "community": 1, "career-change": 2}, + "material": {"story": 2, "data": 3, "opinion": 3, "tutorial": 3, + "announcement": 0, "transcript": 2, "visual": 1, + "question": 0, "curation": 3}, + "minutes": 180, + "constraint": "Articles reach far fewer people than posts. Write one when the artifact " + "matters more than this week's impressions — it is a durable link.", + }, + "newsletter-issue": { + "label": "Newsletter issue", + "goal": {"reach": 2, "authority": 3, "leads": 3, "recruiting": 1, + "community": 3, "career-change": 1}, + "material": {"story": 2, "data": 3, "opinion": 3, "tutorial": 3, + "announcement": 1, "transcript": 2, "visual": 1, + "question": 0, "curation": 3}, + "minutes": 150, + "constraint": "Subscribers are notified every issue. That is a standing promise about " + "cadence and topic — do not start one you cannot hold for six months.", + }, + "comment-as-content": { + "label": "Substantive comment on someone else's post", + "goal": {"reach": 3, "authority": 3, "leads": 2, "recruiting": 2, + "community": 3, "career-change": 3}, + "material": {"story": 2, "data": 3, "opinion": 3, "tutorial": 1, + "announcement": 0, "transcript": 0, "visual": 0, + "question": 2, "curation": 1}, + "minutes": 10, + "constraint": "It has to add something the original missed. Agreement is not a comment. " + "This is the fastest route to visibility from a standing start.", + }, + "repost-with-take": { + "label": "Repost with your own take", + "goal": {"reach": 1, "authority": 2, "leads": 1, "recruiting": 1, + "community": 2, "career-change": 1}, + "material": {"story": 0, "data": 2, "opinion": 3, "tutorial": 0, + "announcement": 1, "transcript": 0, "visual": 1, + "question": 1, "curation": 3}, + "minutes": 15, + "constraint": "Your take must be longer than 'this'. A bare repost spends your credibility " + "on someone else's idea and returns nothing.", + }, +} + + +def pick(goal: str, materials: list, minutes: int, on_camera: bool, + has_real_decision: bool) -> dict: + if goal not in GOALS: + raise ValueError(f"goal must be one of {GOALS}") + bad = [m for m in materials if m not in MATERIALS] + if bad: + raise ValueError(f"unknown material(s): {bad}; choose from {MATERIALS}") + + ranked, excluded = [], [] + for key, spec in FORMATS.items(): + if key == "native-video" and not on_camera and "transcript" not in materials \ + and "visual" not in materials: + excluded.append({"format": key, "reason": + "You said no camera and supplied no footage or transcript."}) + continue + if key == "poll" and not has_real_decision: + excluded.append({"format": key, "reason": + "No real decision behind it. A poll without a follow-up post is a " + "reach trick; declare --has-decision if you will publish what " + "the answers changed."}) + continue + if spec["minutes"] > minutes: + excluded.append({"format": key, "reason": + f"Needs about {spec['minutes']} min; you have {minutes}."}) + continue + mat_scores = [spec["material"][m] for m in materials] or [0] + score = spec["goal"][goal] * 2 + max(mat_scores) + (sum(mat_scores) / len(mat_scores)) + ranked.append({ + "format": key, "label": spec["label"], "score": round(score, 2), + "goal_fit": spec["goal"][goal], + "best_material_fit": max(mat_scores), + "effort_minutes": spec["minutes"], + "constraint": spec["constraint"], + }) + + ranked.sort(key=lambda r: (-r["score"], r["effort_minutes"])) + if not ranked: + return {"decision": "NO_FIT", "exit_code": 3, "ranked": [], "excluded": excluded, + "instruction": "Nothing fits. Either the time budget is too small or the material " + "does not exist yet. Go get the material — the format is the easy part."} + if len(ranked) > 1 and ranked[0]["score"] - ranked[1]["score"] < 1.0: + return {"decision": "ASK", "exit_code": 2, "ranked": ranked[:3], "excluded": excluded, + "instruction": f"'{ranked[0]['label']}' and '{ranked[1]['label']}' score within a " + "point. Ask which the person would actually enjoy making — the one " + "they will repeat beats the one that scores higher once."} + return {"decision": "RECOMMEND", "exit_code": 0, "recommended": ranked[0], + "runners_up": ranked[1:3], "ranked": ranked, "excluded": excluded} + + +def render_human(r: dict) -> str: + lines = [f"Format decision: {r['decision']}", "=" * 52] + if r["decision"] == "RECOMMEND": + rec = r["recommended"] + lines += [f"→ {rec['label']} (score {rec['score']}, ~{rec['effort_minutes']} min)", + f" constraint: {rec['constraint']}", ""] + if r["runners_up"]: + lines.append("Runners-up:") + for u in r["runners_up"]: + lines.append(f" - {u['label']} ({u['score']}) — {u['constraint']}") + else: + lines.append(r["instruction"]) + for u in r.get("ranked", []): + lines.append(f" - {u['label']} ({u['score']}) — {u['constraint']}") + if r.get("excluded"): + lines.append("\nRuled out:") + for e in r["excluded"]: + lines.append(f" - {e['format']}: {e['reason']}") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Rank LinkedIn post formats against goal, material, and time " + "(recommend=0 / ask=2 / no-fit=3).") + ap.add_argument("--goal", choices=GOALS, help="What this post is for.") + ap.add_argument("--material", action="append", default=[], choices=MATERIALS, + help="What you actually have. Repeatable.") + ap.add_argument("--minutes", type=int, default=45, + help="Minutes you will realistically spend (default 45).") + ap.add_argument("--on-camera", action="store_true", + help="You are willing to appear on camera.") + ap.add_argument("--has-decision", action="store_true", + help="A poll would settle a real decision you will report back on.") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Run a built-in example.") + args = ap.parse_args() + + if args.sample: + goal, materials, minutes = "authority", ["data", "tutorial"], 120 + on_camera, decision = False, False + else: + if not args.goal or not args.material: + ap.error("--goal and at least one --material are required (or use --sample)") + goal, materials, minutes = args.goal, args.material, args.minutes + on_camera, decision = args.on_camera, args.has_decision + + try: + result = pick(goal, materials, minutes, on_camera, decision) + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 4 + result["inputs"] = {"goal": goal, "materials": materials, "minutes": minutes, + "on_camera": on_camera, "has_real_decision": decision} + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-content/scripts/post_linter.py b/marketing/linkedin/skills/linkedin-content/scripts/post_linter.py new file mode 100644 index 000000000..1d3d7174e --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/scripts/post_linter.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""post_linter.py — lint a LinkedIn post before it goes out. Score 0-100. + +Checks the things that measurably cost reach or credibility, in four families: + + MECHANICS length caps, the mobile fold, hashtag count, link placement + HOOK does the visible first line survive truncation and earn the click + INTEGRITY engagement bait, unverifiable superlatives, machine-written tells + ACCESSIBILITY Unicode pseudo-bold, emoji load, ALL-CAPS, wall-of-text density + +Blocking findings are the ones with named consequences: a post over the 3,000 +character cap will not publish; Unicode pseudo-bold is read aloud by screen +readers as "mathematical bold small a"; engagement bait is named in LinkedIn's +Professional Community Policies as demoted content. + +Evidence note: the 3,000-character cap and ~140-character mobile fold are +third-party-documented and stable. The reach cost of an in-body external link +(~19% lower median reach in a 1.3M-post third-party study) is a third-party +finding, not a LinkedIn statement — it is flagged as a warning, not a block, +and the reference doc carries the confidence level. + +Exit codes: + 0 SHIP (>= 75 and no blocking findings) + 2 REVISE (50-74, or any blocking finding) + 3 REWRITE (< 50) + +Stdlib only. No network. Deterministic. +""" + +import argparse +import json +import re +import sys + +HARD_LIMIT = 3000 +FOLD_MOBILE = 140 # characters before "…see more" on mobile +FOLD_DESKTOP = 210 +BAND_LOW, BAND_HIGH = 1300, 2500 # highest observed median engagement band + +BAIT_PATTERNS = [ + (r"\bcomment ['\"][\w ]{1,24}['\"]\s*(below|and i'?ll|to get|for the|if you)\b", + "comment-for-lead-magnet"), + (r"\bcomment ['\"]?\w{1,14}['\"]? below\b", "comment-keyword"), + (r"\band i'?ll (dm|send) (you )?(the|it|a|my)\b", "dm-for-engagement"), + (r"\b(like|repost|share) (this )?if you\b", "like-if"), + (r"\btag (someone|a friend|3 people|three people)\b", "tag-someone"), + (r"\bwho else (agrees|thinks|feels)\b", "agreement-farming"), + (r"\bagree\?\s*$", "agree-closer"), + (r"\brepost (this )?(to|so|if)\b", "repost-plea"), +] + +# Phrases that read as machine-written to anyone who reads LinkedIn daily. +SLOP_PATTERNS = [ + (r"\bin today'?s (fast[- ]paced|ever[- ]changing|digital|competitive) (world|landscape|era)\b", + "in-todays-world opener"), + (r"\bdelve into\b", "delve"), + (r"\bit'?s not (just )?about \w+[.,] it'?s about\b", "not-x-its-y"), + (r"\blet that sink in\b", "let-that-sink-in"), + (r"\bgame[- ]chang(er|ing)\b", "game-changer"), + (r"\bunlock (the|your) (power|potential|secret)\b", "unlock-the-potential"), + (r"\bin the ever[- ]evolving\b", "ever-evolving"), + (r"\bhere'?s the (thing|kicker|secret)\b(?=[\s\S]{0,400}\bhere'?s the)", "repeated here's-the"), + (r"\bthe results? (speak for themsel|were nothing short of)\w*", "results-speak"), + (r"\b(revolutioniz|supercharg|turbocharg)\w+", "hype-verb"), + (r"\bas an? (ai|language model)\b", "assistant-artifact"), + (r"\bcertainly[!,]", "assistant-artifact"), +] + +SUPERLATIVES = [ + "the best", "the only", "guaranteed", "never fails", "always works", + "100% of", "everyone knows", "no one is talking about", "nobody talks about", + "the #1", "world-class", "unprecedented", "revolutionary", +] + +GENERIC_OPENERS = [ + "i'm excited to announce", "i am excited to announce", "i'm thrilled", + "i am thrilled", "i'm humbled", "i am humbled", "excited to share", + "happy to share", "proud to announce", "quick thought", "just a thought", +] + +URL_RE = re.compile(r"https?://[^\s)]+|\bwww\.[^\s)]+") +HASHTAG_RE = re.compile(r"(? str: + return text[:n] + + +def lint(text: str, has_image: bool = False) -> dict: + raw = text.rstrip("\n") + low = raw.lower() + findings = [] + n = len(raw) + + def add(sev, family, msg, fix): + findings.append({"severity": sev, "family": family, "finding": msg, "fix": fix}) + + # ---------------- MECHANICS ------------------------------------------ + if n > HARD_LIMIT: + add("blocking", "mechanics", + f"{n} characters — {n - HARD_LIMIT} over the {HARD_LIMIT} cap. LinkedIn will not publish it.", + "Cut to one idea. The section you are most attached to is usually the one to lose.") + elif n < 400: + add("warning", "mechanics", + f"{n} characters. Short posts can work, but under ~400 there is rarely room for a claim " + "and the evidence for it.", + "Either add the specific example, or accept it as a comment rather than a post.") + elif not (BAND_LOW <= n <= BAND_HIGH): + add("info", "mechanics", + f"{n} characters — outside the {BAND_LOW}-{BAND_HIGH} band where third-party studies " + "report the highest median engagement.", + "Not a defect. Worth knowing if reach is the goal for this specific post.") + + urls = URL_RE.findall(raw) + if urls: + add("warning", "mechanics", + f"{len(urls)} external link(s) in the post body. A 1.3M-post third-party study reports " + "~19% lower median reach for a body link (LinkedIn has never confirmed a penalty).", + "Put the link in the first comment and say so in the post: 'link in the comments'. " + "Keep it in the body only when the click IS the goal and you accept the reach cost.") + + tags = HASHTAG_RE.findall(raw) + if len(tags) > 3: + add("warning", "mechanics", + f"{len(tags)} hashtags. Past three they stop being topic signals and start reading as " + "reach-chasing.", + "Keep the two or three that describe what the post is actually about.") + + # ---------------- HOOK ------------------------------------------------ + hook = _visible(raw, FOLD_MOBILE).strip() + first_line = raw.split("\n", 1)[0].strip() + if len(raw) > FOLD_MOBILE: + boundary = max(hook.rfind("."), hook.rfind("?"), hook.rfind("!"), hook.rfind("\n")) + if boundary < FOLD_MOBILE - 90: + add("major", "hook", + f"Nothing completes inside the first {FOLD_MOBILE} characters — the mobile fold. " + "The reader's decision to expand is made on a fragment.", + f"End a sentence before character {FOLD_MOBILE}. Desktop folds around " + f"{FOLD_DESKTOP}, so mobile is the binding constraint.") + gen = [g for g in GENERIC_OPENERS if low.startswith(g) or low[:60].find(g) >= 0] + if gen: + add("major", "hook", + f"Opens with a stock phrase: '{gen[0]}'. It is the most-scrolled-past construction on " + "the platform.", + "Open on the specific thing: the number, the mistake, or the sentence someone said to you.") + if not re.search(r"\d", hook) and "?" not in hook and len(first_line.split()) > 3: + add("info", "hook", + "The visible hook has no number and asks nothing. It can still work, but it is doing " + "it on voice alone.", + "A concrete number or a real question in the first line is the cheapest specificity available.") + + # ---------------- INTEGRITY ------------------------------------------- + bait = sorted({label for pat, label in BAIT_PATTERNS if re.search(pat, low, re.I)}) + if bait: + add("blocking", "integrity", + f"Engagement bait: {', '.join(bait)}. LinkedIn's Professional Community Policies name " + "bait as content it demotes, and readers recognise it.", + "Ask the question the post actually earned, or offer the resource with no toll gate.") + slop = [label for pat, label in SLOP_PATTERNS if re.search(pat, low, re.I)] + if slop: + add("major", "integrity", + f"Machine-written tells: {', '.join(sorted(set(slop)))}.", + "Rewrite those lines the way you would say them out loud. If a phrase could sit in " + "anyone's post about anything, it is not carrying meaning.") + sup = [s for s in SUPERLATIVES if s in low] + if sup: + add("warning", "integrity", + f"Unverifiable superlatives: {', '.join(sup)}.", + "Replace with the bounded version: what you measured, over what period, in what context.") + # Rule-of-three cadence: three consecutive short sentence fragments in a row. + if len(re.findall(r"(?m)^[^\n]{1,45}\.\s*$", raw)) >= 6: + add("info", "integrity", + "Many short standalone one-line sentences ('broetry' cadence). It reads as formatted " + "for the algorithm rather than for a reader.", + "Keep the line breaks that separate ideas; join the ones that only separate clauses.") + + # ---------------- ACCESSIBILITY --------------------------------------- + pseudo = PSEUDO_BOLD_RE.findall(raw) + if pseudo: + add("blocking", "accessibility", + f"{len(pseudo)} Unicode pseudo-bold/italic characters. Screen readers announce these " + "character by character as mathematical symbols, and LinkedIn search does not index " + "them as words.", + "Use plain text. Emphasis comes from line breaks and word order, not from a font hack.") + emoji = EMOJI_RE.findall(raw) + if len(emoji) > 8: + add("warning", "accessibility", + f"{len(emoji)} emoji. Each one is read aloud by name; past a handful the post becomes " + "tiring to hear.", + "Keep the ones doing structural work (list markers), cut decorative ones.") + caps_lines = [ln for ln in raw.split("\n") if len(ln) > 25 and ln.isupper()] + if caps_lines: + add("warning", "accessibility", + f"{len(caps_lines)} ALL-CAPS line(s). Some screen readers spell these out letter by letter.", + "Sentence case. Put the emphasis in the words.") + paragraphs = [p for p in re.split(r"\n\s*\n", raw) if p.strip()] + longest = max((len(p) for p in paragraphs), default=0) + if longest > 600: + add("warning", "accessibility", + f"Longest paragraph is {longest} characters. On a phone that is a wall.", + "Break at the idea boundaries. Three to four lines per block is readable.") + if has_image: + add("info", "accessibility", + "Image attached: LinkedIn supports alt text on images and does not add it for you.", + "Write alt text describing what the image shows, not 'chart'. One sentence is enough.") + + # ---------------- CTA -------------------------------------------------- + tail = raw[-300:].lower() + if "?" not in tail and not re.search(r"\b(tell me|curious|what would you|how do you|" + r"what did|would love to hear|message me|reply)\b", tail): + add("info", "cta", + "The post ends without a question or a next step. Comments are the engagement signal " + "that matters most; nothing here invites one.", + "One genuine question you would actually want answered. Not 'thoughts?'.") + + # ---------------- score ------------------------------------------------- + weights = {"blocking": 22, "major": 11, "warning": 6, "info": 2} + score = max(0, 100 - sum(weights[f["severity"]] for f in findings)) + blocking = any(f["severity"] == "blocking" for f in findings) + if blocking: + verdict, code = "REVISE", 2 + score = min(score, 60) + elif score >= 75: + verdict, code = "SHIP", 0 + elif score >= 50: + verdict, code = "REVISE", 2 + else: + verdict, code = "REWRITE", 3 + + order = {"blocking": 0, "major": 1, "warning": 2, "info": 3} + return { + "score": score, + "verdict": verdict, + "exit_code": code, + "stats": { + "chars": n, "limit": HARD_LIMIT, + "optimal_band": [BAND_LOW, BAND_HIGH], + "hashtags": len(tags), "links_in_body": len(urls), + "emoji": len(emoji), "paragraphs": len(paragraphs), + "visible_before_see_more_mobile": hook, + }, + "findings": sorted(findings, key=lambda f: order[f["severity"]]), + "counts": {sev: sum(1 for f in findings if f["severity"] == sev) + for sev in ("blocking", "major", "warning", "info")}, + "note": ("Character caps and fold positions are third-party-documented and stable. Reach " + "effects (link penalty, length band) come from third-party studies of public " + "posts, not from LinkedIn — see references/hook_and_fold_mechanics.md for the " + "per-claim confidence levels."), + } + + +def render_human(r: dict) -> str: + s = r["stats"] + lines = [f"Post lint: {r['score']}/100 — {r['verdict']}", "=" * 56, + f"{s['chars']}/{s['limit']} chars · {s['hashtags']} hashtags · " + f"{s['links_in_body']} body links · {s['emoji']} emoji · {s['paragraphs']} blocks", + "", "VISIBLE BEFORE \"…see more\" (mobile):", + f" {s['visible_before_see_more_mobile']}", ""] + if r["findings"]: + lines.append("Findings:") + for f in r["findings"]: + lines.append(f" [{f['severity'].upper():<8}] {f['family']}: {f['finding']}") + lines.append(f" fix → {f['fix']}") + else: + lines.append("No findings. Ship it.") + lines += ["", r["note"]] + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Lint a LinkedIn post 0-100 (SHIP=0 / REVISE=2 / REWRITE=3).") + src = ap.add_mutually_exclusive_group() + src.add_argument("--text", help="The post body.") + src.add_argument("--input", help="Read the post from a file ('-' for stdin).") + ap.add_argument("--has-image", action="store_true", + help="The post carries an image or document (adds the alt-text check).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Lint a built-in sample post.") + args = ap.parse_args() + + if args.sample: + text = SAMPLE_POST + elif args.text: + text = args.text + elif args.input: + text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + else: + ap.error("one of --text, --input, or --sample is required") + + result = lint(text, has_image=args.has_image) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-content/scripts/repurpose_splitter.py b/marketing/linkedin/skills/linkedin-content/scripts/repurpose_splitter.py new file mode 100644 index 000000000..333ca5d29 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-content/scripts/repurpose_splitter.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""repurpose_splitter.py — split long source material into standalone LinkedIn units. + +Feed it an article, a talk transcript, a README, or your own notes. It cuts the +source into candidate post units, scores each on whether it can stand alone in a +feed, suggests a format, and refuses the ones that only make sense in context. + +The part that matters is the reuse ledger. Repurposing fails in one specific way: +the same idea goes out three times over eight months and the audience notices +before the author does. `--ledger` keeps a content hash of every unit already +posted, so a unit that has run is skipped by default and shown with its date when +you ask for it. + +The tool never writes the post. It hands you a unit, the reason it can stand +alone, and the gap you have to fill — which is always the same gap: the sentence +only you can write about what this cost you or taught you. + +Exit codes: + 0 usable units found + 2 units found but all are weak (fix the flagged gaps before drafting) + 3 nothing in the source can stand alone — this is one post, not a series + +Stdlib only. No network. Deterministic. +""" + +import argparse +import hashlib +import json +import os +import re +import sys + +MIN_UNIT, MAX_UNIT = 240, 2400 +DANGLING_START = re.compile( + r"^(this|these|those|that|it|they|he|she|such|as (mentioned|noted|we saw|discussed)|" + r"the former|the latter|therefore|thus|however|but|and|so|which|meanwhile|" + r"in conclusion|finally|next,)\b", re.I) + +EVIDENCE_RE = re.compile( + r"\d+\s*(%|x\b|×|ms\b|s\b|min\b|hours?|days?|weeks?|months?|years?)|" + r"[$€£]\s?\d|\b\d{1,3}(,\d{3})+\b|\bversion \d|\b\d+\s*(users|customers|teams|" + r"engineers|requests|rows|queries|tests)\b", re.I) + +STORY_RE = re.compile(r"\b(i |we |my |our |the day|last (year|month|week)|when i|when we|" + r"turned out|i thought|we assumed|the mistake)\b", re.I) +STEP_RE = re.compile(r"(?m)^\s*(\d+[.)]|[-*•]|step \d)", re.I) +OPINION_RE = re.compile(r"\b(should|shouldn'?t|wrong|overrated|underrated|myth|" + r"stop |disagree|the real reason|nobody|most people)\b", re.I) + +SAMPLE_SOURCE = """## Why our onboarding took six weeks + +We measured it for the first time in March. Median time from contract signed to +first real use was 41 days. Nobody in the company believed the number, which is +usually the sign that it is right. + +The instinct was to blame engineering capacity. We had a backlog of integration +work and it was easy to point at. + +## The measurement that changed our mind + +We instrumented each handoff instead of each step. Work-in-progress time was 6 +days. Wait time between owners was 35 days. The work was not slow. The queue was. + +That ratio — 6 to 35 — is the only number from this whole project I still quote. + +## What we changed + +1. One named owner for the whole path rather than one per stage. +2. Deleted the kickoff call, replaced it with a four-question form. 80% of + accounts never needed the call at all. +3. Stopped treating the CRM stage as truth and started measuring first real use. + +Median went to 4 days over the next quarter. + +## What I got wrong + +I spent five weeks building automation for the intake step before anyone measured +where the time went. The automation worked. It saved about four hours across the +whole quarter, against 35 days of queue time sitting untouched two steps later. +""" + + +def split_units(text: str) -> list: + """Heading-aware split, falling back to paragraph accumulation.""" + heading_blocks = re.split(r"(?m)^#{1,6}\s+", text) + if len(heading_blocks) > 2: + units = [] + for block in heading_blocks[1:]: + line, _, rest = block.partition("\n") + body = rest.strip() + if body: + units.append({"title": line.strip(), "text": body}) + if units: + return units + + units, buf = [], [] + for para in [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]: + buf.append(para) + joined = "\n\n".join(buf) + if len(joined) >= MIN_UNIT: + units.append({"title": "", "text": joined}) + buf = [] + if buf: + joined = "\n\n".join(buf) + if units and len(joined) < MIN_UNIT: + units[-1]["text"] += "\n\n" + joined + else: + units.append({"title": "", "text": joined}) + return units + + +def suggest_format(unit_text: str) -> str: + if STEP_RE.search(unit_text) and len(EVIDENCE_RE.findall(unit_text)) >= 2: + return "document-carousel" + if STORY_RE.search(unit_text): + return "text-post" + if OPINION_RE.search(unit_text): + return "text-post" + if EVIDENCE_RE.search(unit_text): + return "image-post" + return "text-post" + + +def score_unit(unit: dict) -> dict: + text = unit["text"].strip() + first = text.split("\n", 1)[0].strip() + gaps, score = [], 0 + + if MIN_UNIT <= len(text) <= MAX_UNIT: + score += 25 + elif len(text) < MIN_UNIT: + gaps.append({"gap": "too-thin", + "detail": f"{len(text)} chars — not enough to make a claim and back it.", + "fix": "Merge with the neighbouring section or drop it."}) + else: + score += 10 + gaps.append({"gap": "too-long", + "detail": f"{len(text)} chars — over the {MAX_UNIT}-char working ceiling.", + "fix": "Split again at the strongest internal boundary; two posts beat one crammed one."}) + + if DANGLING_START.match(first): + gaps.append({"gap": "dangling-reference", + "detail": f"Opens with '{first.split()[0]}' — it refers to something the " + "reader never saw.", + "fix": "Rewrite the first sentence to name the thing outright."}) + else: + score += 25 + + ev = EVIDENCE_RE.findall(text) + if ev: + score += 25 + else: + gaps.append({"gap": "no-evidence", + "detail": "No number, duration, or measurable detail.", + "fix": "Either add the real figure from the source, or route this to an " + "opinion post where the argument does the work."}) + + sentences = [s for s in re.split(r"[.!?](?:\s|$)", text) if len(s.strip()) > 15] + if len(sentences) >= 3: + score += 25 + else: + gaps.append({"gap": "fragment", + "detail": f"{len(sentences)} substantive sentence(s) — reads as a note, " + "not a post.", + "fix": "Expand or merge."}) + + return { + "title": unit["title"], + "chars": len(text), + "standalone_score": score, + "suggested_format": suggest_format(text), + "hook_candidate": first[:140], + "gaps": gaps, + "text": text, + "author_gap": ("Every unit here is source material, not a post. Add the first-person " + "sentence only you can write: what it cost, what you assumed, or what " + "you would do differently."), + } + + +def unit_hash(text: str) -> str: + normalized = re.sub(r"\s+", " ", text.strip().lower()) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16] + + +def load_ledger(path: str) -> dict: + if path and os.path.isfile(path): + try: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + except (json.JSONDecodeError, OSError): + return {} + return {} + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Split long source material into standalone LinkedIn units " + "(usable=0 / all-weak=2 / not-splittable=3).") + src = ap.add_mutually_exclusive_group() + src.add_argument("--input", help="Source file to split ('-' for stdin).") + ap.add_argument("--ledger", help="JSON file tracking units already posted " + "(created on --record if absent).") + ap.add_argument("--record", action="append", default=[], + help="Mark a unit index as posted in the ledger. Repeatable.") + ap.add_argument("--posted-on", default="", + help="Date string stored with --record entries (e.g. 2026-08-25).") + ap.add_argument("--show-used", action="store_true", + help="Include units already in the ledger instead of skipping them.") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Split a built-in sample article.") + args = ap.parse_args() + + if args.sample: + text = SAMPLE_SOURCE + elif args.input: + text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + else: + ap.error("--input or --sample is required") + + ledger = load_ledger(args.ledger) if args.ledger else {} + raw_units = split_units(text) + scored = [] + for idx, u in enumerate(raw_units): + s = score_unit(u) + s["index"] = idx + s["hash"] = unit_hash(s["text"]) + prev = ledger.get(s["hash"]) + s["already_posted"] = bool(prev) + s["posted_on"] = prev.get("posted_on") if isinstance(prev, dict) else prev + scored.append(s) + + if args.record: + if not args.ledger: + print("ERROR: --record requires --ledger", file=sys.stderr) + return 4 + try: + wanted = {int(i) for i in args.record} + except ValueError: + print("ERROR: --record takes unit indexes (integers)", file=sys.stderr) + return 4 + for s in scored: + if s["index"] in wanted: + ledger[s["hash"]] = {"title": s["title"] or s["hook_candidate"][:60], + "posted_on": args.posted_on or "unspecified"} + with open(args.ledger, "w", encoding="utf-8") as fh: + json.dump(ledger, fh, indent=2) + + visible = scored if args.show_used else [s for s in scored if not s["already_posted"]] + # A unit with a structural gap is not usable however well it scores elsewhere: + # a dangling opener or a fragment fails in the feed regardless of its evidence. + disqualifying = {"too-thin", "dangling-reference", "fragment"} + usable = [s for s in visible + if s["standalone_score"] >= 75 + and not any(g["gap"] in disqualifying for g in s["gaps"])] + skipped = len(scored) - len(visible) + + if not scored or (len(scored) == 1 and scored[0]["standalone_score"] < 75): + decision, code = "NOT_SPLITTABLE", 3 + elif usable: + decision, code = "USABLE", 0 + else: + decision, code = "ALL_WEAK", 2 + + result = { + "decision": decision, + "exit_code": code, + "units_found": len(scored), + "units_skipped_as_already_posted": skipped, + "usable_units": len(usable), + "units": sorted(visible, key=lambda s: -s["standalone_score"]), + "ledger_path": args.ledger, + "rule": ("A unit is source material, not a post. The tool never fabricates the " + "first-person line — that is the author's job and it is the only part " + "of a repurposed post that is actually new."), + } + + if args.output == "json": + print(json.dumps(result, indent=2)) + else: + print(f"Repurpose: {decision} — {len(scored)} unit(s), {len(usable)} usable" + + (f", {skipped} skipped as already posted" if skipped else "")) + print("=" * 64) + for s in result["units"]: + flag = " [ALREADY POSTED %s]" % s["posted_on"] if s["already_posted"] else "" + print(f"\n#{s['index']} score {s['standalone_score']}/100 " + f"{s['chars']} chars → {s['suggested_format']}{flag}") + if s["title"]: + print(f" title: {s['title']}") + print(f" hook : {s['hook_candidate']}") + for g in s["gaps"]: + print(f" gap : [{g['gap']}] {g['detail']}") + print(f" fix → {g['fix']}") + print(f"\n{result['rule']}") + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-engagement/SKILL.md b/marketing/linkedin/skills/linkedin-engagement/SKILL.md new file mode 100644 index 000000000..747fbce2f --- /dev/null +++ b/marketing/linkedin/skills/linkedin-engagement/SKILL.md @@ -0,0 +1,100 @@ +--- +name: linkedin-engagement +description: Use when someone wants to grow reach through comments, replies, groups, or outreach on LinkedIn — a commenting roster, a connection request note, a DM or InMail, a networking plan, or a check on whether their outreach volume is safe. Triggers on "who should I engage with", "write a connection request", "cold DM", "LinkedIn outreach", "networking strategy", "how many invites can I send". Builds a weekly comment roster inside a real time budget, assembles one message at a time and refuses templates, and caps volume against LinkedIn's limits. Nothing is ever sent. +license: MIT +metadata: + version: 1.0.0 + author: Alireza Rezvani + category: marketing + updated: 2026-08-25 +--- + +# LinkedIn Engagement — comments first, outreach second + +From a standing start your posts reach almost nobody, and publishing harder does not fix it. +A substantive comment on a post that already has an audience puts your name, headline, and a +paragraph of thinking in front of people already reading about your subject, for six minutes +of work — the cheapest distribution on the platform, and the most badly used. + +**Nothing here sends anything** — no credentials, no API calls. Automated connecting, +messaging, commenting, liking, and sharing are prohibited by User Agreement §8.2. + +## Workflow + +**1. Build the comment roster.** Name 8-12 accounts they would read anyway, with audience +overlap and rough size tier, then: + +```bash +python3 scripts/comment_target_planner.py --account "Priya Raman:5:4:larger" \ + --account "Tomas Lind:5:3:peer" --minutes-per-day 18 --output human +``` + +Tiers are `huge` (10x+, crowded), `larger` (2-10x, the best ratio), `peer` (~1x, where +reciprocity compounds), `smaller` (goodwill). The roster caps any account at twice a week — +commenting daily on one person reads as following them around — and keeps the huge tier under +half of any day. It builds the roster, never the comments: a generated comment is exactly +what §8.2 names, and it is recognisable anyway. + +**2. Outreach — check the volume before writing anything.** + +```bash +python3 scripts/outreach_volume_guard.py --invites 20 --pending 5 --minutes 120 \ + --acceptance 0.42 --output human +``` + +Exit 0 safe / 2 tight / 3 over a cap or the time budget / **4 refused** above 40 invitations +a day, because nobody reads that many profiles and writes that many specific lines. Pending +invitations count against the weekly limit (observed around 100), and acceptance below 20% is +a stop signal — it is the pattern LinkedIn reviews, and the targeting is wrong. + +**3. Write one message, for one person.** + +```bash +python3 scripts/outreach_message_builder.py --type connection \ + --recipient "Priya" --specific-line "..." --reason "..." --output human +``` + +It refuses without a person-specific line, refuses an ask in a first-touch connection note, +and enforces the 200-character cap (300 with `--premium`). In large third-party samples a +note barely moves acceptance (~26.4% either way) but roughly doubles the post-accept reply +rate: **the note earns the conversation, not the meeting.** + +**4. The order that works.** Comment on their work for two weeks. Then invite, referencing +something specific from that reading. Then, after acceptance and a pause, ask once, small. + +## Rules + +- **Nothing is auto-sent.** Ever. The user pastes and sends, one person at a time. +- **No engagement pods.** Coordinated reciprocal commenting is inauthentic engagement under + §8.2 regardless of who pressed the key. Build a real reciprocity list instead. +- **Every message carries a line that could only have been written for that person.** +- **Never paste the same comment twice.** Identical comments at volume are the definition of + the thing §8.2 prohibits. +- **One follow-up, a week later, only with something new to say.** +- **Agreement is not a comment.** Add the counter-example, the number, or the case where it + breaks — or skip the slot. + +## Scripts + +| Script | Role | +|---|---| +| [`scripts/comment_target_planner.py`](scripts/comment_target_planner.py) | Weekly roster from scored accounts, inside a time budget, with per-account and per-tier caps. | +| [`scripts/outreach_message_builder.py`](scripts/outreach_message_builder.py) | Assembles one message; refuses templates, premature asks, and 14 dead phrases. | +| [`scripts/outreach_volume_guard.py`](scripts/outreach_volume_guard.py) | Caps invitations against the observed weekly limit, pending backlog, acceptance floor, and the hours available. | + +## References and assets + +- [`references/comment_strategy.md`](references/comment_strategy.md) — tiers, what a comment competes on, pods, replying to your own posts (7 sources) +- [`references/outreach_ethics_and_benchmarks.md`](references/outreach_ethics_and_benchmarks.md) — the benchmark numbers with their provenance, and the personalisation claim corrected (7 sources) + +- [`assets/outreach_worksheet.md`](assets/outreach_worksheet.md) — fillable per-person prep sheet +- [`assets/example_outreach.json`](assets/example_outreach.json) — input shape for the builder + +## Distinct from + +- **`linkedin-content`** — writes posts. A comment is a different craft on a different budget. +- **`marketing-skill/cold-email`** — email. Different channel, law, and caps. +- **`business-growth/`, `commercial/`** — sales process and deal economics, not networking. + +--- +**Version:** 1.0.0 diff --git a/marketing/linkedin/skills/linkedin-engagement/assets/example_outreach.json b/marketing/linkedin/skills/linkedin-engagement/assets/example_outreach.json new file mode 100644 index 000000000..f79a67c75 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-engagement/assets/example_outreach.json @@ -0,0 +1,9 @@ +{ + "_comment": "Input shape for outreach_message_builder.py. This example passes at exit 0: the specific line names an artifact and the part that mattered, the reason is a clause, and there is no ask - because this is a first-touch connection note and the note's job is the conversation, not the meeting.", + "type": "connection", + "recipient": "Priya", + "specific_line": "Your teardown of the dbt exposures rollout matched what broke for us at step three.", + "reason": "I am working the same problem from the platform side and would like to follow what you publish.", + "ask": "", + "premium": false +} diff --git a/marketing/linkedin/skills/linkedin-engagement/assets/outreach_worksheet.md b/marketing/linkedin/skills/linkedin-engagement/assets/outreach_worksheet.md new file mode 100644 index 000000000..23491ca14 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-engagement/assets/outreach_worksheet.md @@ -0,0 +1,74 @@ +# Outreach Prep Sheet — one per person + +If you cannot fill section 2 without opening a template, do not send the message. The +person-specific line is the entire difference between outreach and spam, and +`outreach_message_builder.py` refuses without it. + +--- + +## 1. Who + +- Name (spelled the way they spell it): ______ +- What they actually do (not their title): ______ +- How you found them: ______ +- Have you commented on their work in the last two weeks? ______ + +If the last answer is no, consider doing that first. Comment for two weeks, then invite. +That order converts better than any wording optimisation. + +## 2. The specific line — the one that could not be sent to anyone else + +Name the artifact and the part of it that mattered: + +> ______________________________________________________________ + +Checks: +- Does it name something they made, said, or decided? ______ +- Could this sentence be sent to anyone else on your list? If yes, rewrite it. ______ +- Is it a disagreement or a use, rather than a compliment? (Better if so.) ______ + +## 3. The reason + +One clause: what you want to follow, learn, or compare notes on. + +> ______________________________________________________________ + +## 4. The ask — **not in a first-touch connection note** + +Leave blank for a connection request. The note's job is the conversation, not the meeting: +in large third-party samples a note barely moves acceptance (~26.4% either way) but roughly +doubles the reply rate after acceptance. + +For a later message, keep it bounded and small: + +> ______________________________________________________________ + +## 5. Volume check before sending + +- Invitations already pending: ______ +- New invitations planned this week: ______ +- Recent acceptance rate: ______ (below 20% → stop and fix targeting) + +```bash +python3 ../scripts/outreach_volume_guard.py --invites --pending \ + --minutes --acceptance <0.xx> --output human +``` + +## 6. Assemble and check + +```bash +python3 ../scripts/outreach_message_builder.py --type connection \ + --recipient "..." --specific-line "..." --reason "..." --output human +``` + +Cap: 200 characters free, 300 with `--premium`. + +## 7. Follow-up rule + +One follow-up, at least a week later, **only with something new to say**. "Just bumping this +up" is not new information. A second follow-up with nothing new is the point at which you +become the thing you were avoiding. + +- Date sent: ______ +- Accepted? ______ +- Follow-up sent (date, and what was new): ______ diff --git a/marketing/linkedin/skills/linkedin-engagement/references/comment_strategy.md b/marketing/linkedin/skills/linkedin-engagement/references/comment_strategy.md new file mode 100644 index 000000000..50310699b --- /dev/null +++ b/marketing/linkedin/skills/linkedin-engagement/references/comment_strategy.md @@ -0,0 +1,145 @@ +# Comment Strategy — the cheapest distribution on LinkedIn + +From a standing start, your posts reach almost nobody. That is not a failure of +the writing; it is what an empty follower graph and a relevance-based retrieval +system produce together. Publishing harder does not fix it. + +Commenting does. A substantive comment on a post that already has an audience +puts your name, your headline, and one paragraph of your thinking in front of +people who are already reading about your subject. It costs six minutes. + +Most people do it badly, in an identifiable way. + +--- + +## What a comment is competing on + +A reader scanning a comment thread is making the same decision they make in the +feed, with less patience. Your comment competes against forty others, most of +which say some version of "great post". The bar for standing out is therefore +much lower than it looks, and it is entirely about **adding something the post +did not say.** + +Five things that qualify: + +1. **A counter-example.** "This holds until the team crosses about fifteen + people, and then the handoff cost dominates." +2. **A number.** "We measured this: 6 days of work, 35 days of queue." +3. **The case where it breaks.** The most valuable comment type, and the rarest. +4. **A specific mechanism.** Why the thing the author observed happens. +5. **A question only someone who read it properly could ask.** + +Two that do not: agreement, and a summary of the post the author just wrote. + +## Tiering, and why the biggest accounts are the wrong target + +`comment_target_planner.py` scores accounts on audience overlap, posting +frequency, and tier. + +| Tier | Relative size | Factor | Reality | +|---|---|---|---| +| **huge** | 10x+ | 1.0 | Crowded. 400 comments, most never read. Worth it only if you are early *and* excellent | +| **larger** | 2-10x | 1.5 | The best ratio of reachable audience to competition | +| **peer** | ~1x | 1.3 | Reciprocity compounds. These relationships still exist in a year | +| **smaller** | <0.5x | 0.9 | Low reach today, high goodwill, and some will not be smaller for long | + +The instinct is to comment on the biggest accounts in the field. It is the worst +use of the time: your comment is one of hundreds, the author will not read it, +and their audience is scanning past the whole thread. + +**The under-rated tier is peer.** Reciprocity is real and it is mutual — the +people at your size who show up in your comments now are the ones whose audience +overlaps yours most, and the relationship runs both directions. + +## The rules the planner enforces + +- **No account more than twice a week.** Commenting daily on one person reads as + following them around, and it exhausts the goodwill it earns. +- **At least one peer slot per day**, for the reason above. +- **No more than half a day's slots in the huge tier.** + +## What the planner will not do + +It does not write comments. This is the whole point of the design: a comment +written by a tool is exactly the thing LinkedIn's User Agreement §8.2 names when +it prohibits automated methods to "create, comment on, like, share, or re-share +posts, or otherwise drive inauthentic engagement." + +More practically: a generated comment is recognisable, and being recognised as +someone who generates comments is worse than not commenting. + +## Engagement pods + +A pod is a group that agrees to reciprocally comment on each other's posts, +usually within the first hour. The pitch is that it is just humans helping +humans. + +It is prohibited. The User Agreement language is "otherwise drive inauthentic +engagement", and coordinated reciprocal commenting on schedule is the central +example. `linkedin_policy_gate.py` refuses pod requests at exit 4. + +The legitimate version of the same instinct is the reciprocity list: a set of +people whose work you genuinely read, whom you comment on because you have +something to say. It is slower, it produces the same relationships, and it +survives an audit. + +## Replying to your own comments + +Half of the value of a post is in the thread underneath it. Replies to your own +post are not a bonus round: + +- Reply to every substantive comment within 24 hours, and to the early ones + within the hour if you can. +- Reply with something, not "thanks!". The reply is visible to everyone who opens + the thread, and it is a second chance to say the thing. +- **Reply to replies on your comments elsewhere.** That sub-thread is where + people actually meet you. + +`cadence_planner.py` budgets 20 minutes per published post for this, and treats +it as part of the post rather than an extra. + +## Groups and communities + +LinkedIn Groups are mostly dormant. The exceptions are narrow, actively moderated +professional groups, where the signal is high because the population is small. +The test is whether posts in the group get real replies from named humans; if the +last three posts have no comments, it is a graveyard and posting there is a +donation to nobody. + +The higher-yield version of the same instinct is off-LinkedIn communities where +your audience already talks — a Slack, a Discord, a mailing list, a conference — +and bringing what you learn there back to LinkedIn as posts. + +## Measuring it + +Commenting does not produce a metric LinkedIn will show you cleanly. The +observable signals, in order of reliability: + +1. Profile views trending up in weeks you commented consistently. +2. Connection requests *from* people whose posts you commented on. +3. Named references: someone says "I saw your comment on X's post". + +None of these are clean attribution, and there is no honest way to make them so. +Treat commenting as a fixed practice rather than a tracked channel. + +--- + +## Sources + +1. LinkedIn. **User Agreement §8.2** — the explicit prohibition on automated or + inauthentic commenting, liking, and sharing. +2. Granovetter, M. **"The Strength of Weak Ties."** *American Journal of + Sociology*, 1973 — why the peer and larger tiers, not the huge tier, are where + opportunity actually flows. +3. Burt, R. **Structural Holes: The Social Structure of Competition** (1992) — + brokerage between clusters; commenting across adjacent fields is exactly this. +4. Cialdini, R. **Influence: The Psychology of Persuasion** — reciprocity and + liking, and why they are destroyed by being made transactional. +5. Grant, A. **Give and Take** — givers who succeed do so through specific, + bounded generosity, not indiscriminate availability. +6. Nielsen, J. **"The 90-9-1 Rule for Participation Inequality"** (NN/g) — the + small population of people who comment at all, and why being one of them is + cheap differentiation. +7. LinkedIn Engineering. **"Understanding feed dwell time to improve LinkedIn + feed ranking."** — the mechanism by which threads that hold attention are + measured. diff --git a/marketing/linkedin/skills/linkedin-engagement/references/outreach_ethics_and_benchmarks.md b/marketing/linkedin/skills/linkedin-engagement/references/outreach_ethics_and_benchmarks.md new file mode 100644 index 000000000..9c9211281 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-engagement/references/outreach_ethics_and_benchmarks.md @@ -0,0 +1,147 @@ +# Outreach Ethics and Benchmarks — what the numbers say, and what they cost + +Cold outreach on LinkedIn works and is widely hated, and both facts have the same +cause: it is cheap to send and expensive to receive. The volume that makes it +economically attractive is the volume that makes it spam, and the platform +enforces against the pattern rather than the intent. + +This document holds the numbers, with their provenance, and the rules that keep +the practice on the right side of the line. + +--- + +## The benchmarks, with a correction + +Third-party outreach studies are large but self-selected: the data comes from +users of outreach tooling, measured by the vendors of that tooling. Treat all of +it 🟡. + +| Metric | Reported | Source class | +|---|---|---| +| Connection acceptance, platform-wide average | ~26-29% | 🟡 multi-million-touch vendor datasets | +| Acceptance with a personalised note | ~26.4% | 🟡 same | +| Acceptance with **no** note | ~26.4% | 🟡 same | +| Post-accept reply rate, **without** a note | ~5.4% | 🟡 | +| Post-accept reply rate, **with** a note | ~9.4% | 🟡 | +| Cold InMail response | 10-25% typical | 🟡 | +| Individually sent vs bulk InMail | ~15% higher response individually | 🟢 LinkedIn-reported | + +**The correction that matters.** The widely repeated claim is that a personalised +note roughly triples acceptance (≈45% vs ≈15%). The largest available datasets do +not support it: acceptance is close to identical either way (26.42% vs 26.37% in +one ~13M-touch sample). What the note does move — and moves a lot — is the +**reply rate after acceptance**, roughly 5.4% → 9.4%. + +The practical conclusion is the same as the folklore's, but the reasoning is +different and it changes what you write. **The note is not there to get the +connection. It is there to earn the conversation.** Which means it should be +about them and about why this conversation, not about you and what you sell. + +`outreach_message_builder.py` encodes exactly this: it refuses an ask in a +first-touch connection note, because the note's job is the conversation, not the +meeting. + +## The character caps + +| Message type | Free | Premium | Confidence | +|---|---|---|---| +| Connection request note | 200 | 300 | 🟡 | +| Direct message | ~1,800 | ~1,800 | 🟡 | +| InMail body | ~1,900 | ~1,900 | 🟡 | + +Two hundred characters is roughly two sentences. That is the entire budget, and +it is why the builder puts the person-specific line first and treats everything +else as optional. + +## The person-specific line + +The builder refuses to assemble a message without one. It is the single +discriminator between outreach and spam, and it is checkable: **could this +sentence have been sent to anyone else on your list?** If yes, it is not +specific. + +What qualifies: +- Something they published, named, with the part that mattered to you. +- A decision they made that you are facing. +- A disagreement. "Your point about X is the opposite of what we found" is a + better opener than any compliment. + +What does not: +- "I came across your profile." +- Their job title, company, or industry. +- "As a fellow [category]." +- Praise with no specifics, which reads as a mail merge because it usually is. + +## Phrases to delete + +The builder flags these because they mark a message as bulk before anyone reads +the content: *I came across your profile · I'd love to pick your brain · hope +this finds you well · quick question · just following up · touch base · synergy · +I'll keep it short · as a fellow · I see we're both in · let's connect.* + +"Pick your brain" deserves a specific note: it asks for unbounded unpaid time +with no bounded question attached. The version that works is the opposite — +one specific question, answerable in two sentences, with an explicit "no reply +needed if you're busy". + +## Volume, and what actually triggers restrictions + +Covered in full in `linkedin-skills/references/policy_and_account_safety.md`. The +short version: + +- Weekly invitation limit observed around **100**, adjusted per account, with + **pending invitations counting against it**. 🟡 +- Withdrawn invitations cannot be re-sent to the same person for about three + weeks. 🟡 +- **Low acceptance rate at volume is the discriminating signal**, not volume + alone. Below ~20%, stop and fix the targeting rather than pushing through. +- Machine-regular pacing looks automated whether or not it is. + +`outreach_volume_guard.py` refuses above 40 invitations per day outright: nobody +reads that many profiles and writes that many specific lines, so the plan is an +automation plan whatever the intent. + +## Follow-up + +One follow-up, at least a week later, **only if you have something new to say**. +A second follow-up with no new information is the point at which you become the +thing you were avoiding. + +"Just bumping this up" is not new information. A relevant thing that happened +since, or a genuinely useful link with nothing attached, is. + +## The order that actually works + +1. **Comment on their work for two weeks.** Substantively, in public. +2. **Then send the invitation**, referencing something specific from that + reading — which by now you have actually done. +3. **After acceptance, do nothing for a while.** Then ask, once, small. + +This converts far better than any note optimisation, and it is not a growth hack +— it is what happens when you talk to people whose work you read. + +## When outreach is the wrong channel + +If the target number is the point rather than the people, LinkedIn outreach is +the wrong instrument. Advertising exists, it is designed for volume, it is +honest about what it is, and it does not risk the account you spent a year +building. + +--- + +## Sources + +1. LinkedIn. **User Agreement §8.2** — automated messaging, bulk sending, and + inauthentic engagement. +2. LinkedIn Help. **"Account restrictions"** and **"Automated activity on + LinkedIn"** — what LinkedIn says it acts on. +3. Expandi. **LinkedIn Outreach Benchmarks** (2026 edition, ~13.2M data points) — + 🟡 acceptance with and without a note, and the post-accept reply-rate gap. +4. Cleverly. **LinkedIn Benchmarks** (~20M outreach touches) — 🟡 connection and + reply rates by industry. +5. LinkedIn Sales Solutions. **InMail best-practice guidance** — 🟢 the + individually-sent vs bulk response-rate difference. +6. Cialdini, R. **Influence** — reciprocity and commitment, and why an ask + arriving before any relationship inverts both. +7. Granovetter, M. **"The Strength of Weak Ties"** (1973) — the structural reason + cold outreach works at all, and why it works better after public visibility. diff --git a/marketing/linkedin/skills/linkedin-engagement/scripts/comment_target_planner.py b/marketing/linkedin/skills/linkedin-engagement/scripts/comment_target_planner.py new file mode 100644 index 000000000..373df71c6 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-engagement/scripts/comment_target_planner.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""comment_target_planner.py — build a weekly commenting roster inside a real time budget. + +Commenting is the highest-return activity available to an account with no +distribution, and it is the one people do worst: they comment on the same three +huge accounts, in threads with 400 other comments, saying "great post". + +This builds a five-day roster from the accounts you name, scored on audience +overlap, how often they actually post, and how crowded their threads are. It +enforces three rules that keep the roster from becoming a pattern: + + - no account appears more than twice a week (commenting daily on one person + reads as following them around, and it exhausts the goodwill it earns) + - at least one peer-tier account per day, because reciprocity is the part that + compounds + - never more than half a day's slots in the "huge" tier, where being early + matters more than being right and most comments are never read + +The output is a roster, not comment text. Comment text that a tool wrote is the +thing this whole approach is trying to avoid. + +Exit codes: + 0 roster built + 2 budget below one comment a day — a reduced roster is returned + 3 no usable accounts supplied + +Stdlib only. No network. Nothing is posted. +""" + +import argparse +import json +import sys + +MINUTES_PER_COMMENT = 6 +DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] +MAX_PER_ACCOUNT_PER_WEEK = 2 + +TIERS = { + "huge": {"factor": 1.0, + "note": "10x+ your size. Crowded threads — only worth it if you are early and add " + "something the post missed."}, + "larger": {"factor": 1.5, + "note": "2-10x your size. The best ratio of reachable audience to competition."}, + "peer": {"factor": 1.3, + "note": "Roughly your size. Reciprocity compounds here; these are the relationships " + "that still exist in a year."}, + "smaller": {"factor": 0.9, + "note": "Smaller than you. Low reach today, high goodwill, and some of them will " + "not be smaller for long."}, +} + +SAMPLE_ACCOUNTS = [ + "Priya Raman:5:4:larger", + "Data Council:4:6:huge", + "Tomas Lind:5:3:peer", + "Anna Beck:4:2:peer", + "Jules Okafor:3:5:larger", + "Sam Idris:5:1:smaller", +] + + +def parse_account(spec: str) -> dict: + parts = spec.split(":") + if len(parts) != 4: + raise ValueError(f"expected name:overlap:posts_per_week:tier — got '{spec}'") + name, overlap, freq, tier = parts + tier = tier.strip().lower() + if tier not in TIERS: + raise ValueError(f"unknown tier '{tier}' in '{spec}'; choose from {sorted(TIERS)}") + try: + overlap_i, freq_i = int(overlap), int(freq) + except ValueError: + raise ValueError(f"overlap and posts_per_week must be integers in '{spec}'") + if not 1 <= overlap_i <= 5: + raise ValueError(f"overlap must be 1-5 in '{spec}'") + if freq_i < 0: + raise ValueError(f"posts_per_week cannot be negative in '{spec}'") + return {"name": name.strip(), "overlap": overlap_i, "posts_per_week": freq_i, "tier": tier} + + +def score(acct: dict) -> float: + # Posting frequency has diminishing returns: an account posting daily does not + # give you five times the opportunity of one posting twice a week, because you + # can only comment on so many of them well. + freq_factor = min(acct["posts_per_week"], 5) ** 0.5 + return round(acct["overlap"] * freq_factor * TIERS[acct["tier"]]["factor"], 2) + + +def build(accounts: list, minutes_per_day: int) -> dict: + if not accounts: + return {"verdict": "NO_ACCOUNTS", "exit_code": 3, + "instruction": "Name the accounts first. Ten is plenty: the people whose audience " + "you want, whose posts you would read anyway, and who post often " + "enough to give you an opening each week."} + + per_day = minutes_per_day // MINUTES_PER_COMMENT + findings = [] + verdict, code = "ROSTER", 0 + if per_day < 1: + per_day = 1 + verdict, code = "BELOW_BUDGET", 2 + findings.append({ + "severity": "warning", "area": "budget", + "finding": f"{minutes_per_day} min/day is under the {MINUTES_PER_COMMENT} minutes one " + "substantive comment takes.", + "fix": "Comment on alternate days rather than writing a worse comment daily. " + "The roster below assumes one comment per listed day.", + }) + + scored = sorted(({**a, "score": score(a)} for a in accounts), + key=lambda a: (-a["score"], a["name"])) + tier_counts = {t: sum(1 for a in scored if a["tier"] == t) for t in TIERS} + if tier_counts["peer"] == 0: + findings.append({ + "severity": "warning", "area": "mix", + "finding": "No peer-tier accounts. Every relationship in the list is one-directional.", + "fix": "Add three or four people at roughly your size. That is where reciprocal " + "attention comes from, and it is the part that still pays in a year.", + }) + if tier_counts["huge"] > len(scored) / 2: + findings.append({ + "severity": "warning", "area": "mix", + "finding": f"{tier_counts['huge']}/{len(scored)} accounts are huge. Their threads are " + "crowded and most comments there are never read.", + "fix": "Rebalance toward 'larger' and 'peer'. Being the best comment on a 40-comment " + "post beats being the 300th on a 400-comment one.", + }) + + used = {a["name"]: 0 for a in scored} + roster, cursor = [], 0 + for day in DAYS: + slots, huge_today, peer_today = [], 0, 0 + attempts = 0 + while len(slots) < per_day and attempts < len(scored) * 4: + acct = scored[cursor % len(scored)] + cursor += 1 + attempts += 1 + if used[acct["name"]] >= MAX_PER_ACCOUNT_PER_WEEK: + continue + if any(s["name"] == acct["name"] for s in slots): + continue + if acct["tier"] == "huge" and huge_today >= max(1, per_day // 2): + continue + slots.append({"name": acct["name"], "tier": acct["tier"], + "score": acct["score"], "why": TIERS[acct["tier"]]["note"]}) + used[acct["name"]] += 1 + if acct["tier"] == "huge": + huge_today += 1 + if acct["tier"] == "peer": + peer_today += 1 + if per_day >= 2 and peer_today == 0 and tier_counts["peer"]: + findings.append({ + "severity": "info", "area": "mix", + "finding": f"{day} has no peer-tier slot after the weekly cap was applied.", + "fix": "Add another peer account to the list so every day has one.", + }) + roster.append({"day": day, "comments": slots, + "minutes": len(slots) * MINUTES_PER_COMMENT}) + + total = sum(len(d["comments"]) for d in roster) + exhausted = [n for n, c in used.items() if c >= MAX_PER_ACCOUNT_PER_WEEK] + if total < per_day * len(DAYS): + findings.append({ + "severity": "warning", "area": "supply", + "finding": f"Only {total} of {per_day * len(DAYS)} weekly slots could be filled without " + f"exceeding the {MAX_PER_ACCOUNT_PER_WEEK}-per-account cap.", + "fix": f"Add more accounts. You need roughly " + f"{-(-per_day * len(DAYS) // MAX_PER_ACCOUNT_PER_WEEK)} to fill the week.", + }) + + return { + "verdict": verdict, + "exit_code": code, + "minutes_per_day": minutes_per_day, + "comments_per_day": per_day, + "weekly_slots_filled": total, + "accounts_scored": scored, + "roster": roster, + "at_weekly_cap": exhausted, + "findings": findings, + "comment_rules": [ + "Add something the post did not say. A counter-example, a number, the case where it " + "breaks. Agreement is not a comment.", + "Never paste the same comment twice. Identical comments across accounts are the " + "definition of inauthentic engagement under User Agreement §8.2.", + "Comment because you read it. If you have nothing to add, skip the slot — an empty " + "slot costs nothing and a filler comment costs credibility.", + "Two to four sentences. A comment longer than the post is a post; go write it.", + "Reply to replies on your comment. That thread is where people actually meet you.", + ], + } + + +def render_human(r: dict) -> str: + if r["verdict"] == "NO_ACCOUNTS": + return "Comment roster: NO_ACCOUNTS\n" + "=" * 40 + "\n" + r["instruction"] + lines = [f"Comment roster: {r['verdict']} " + f"({r['comments_per_day']}/day, {r['minutes_per_day']} min/day)", + "=" * 60] + for day in r["roster"]: + names = ", ".join(f"{c['name']} [{c['tier']}]" for c in day["comments"]) or "(no slots)" + lines.append(f" {day['day']} {names} — {day['minutes']} min") + lines.append("\nScored accounts:") + for a in r["accounts_scored"]: + lines.append(f" {a['score']:>5} {a['name']:<22} {a['tier']:<8} " + f"overlap {a['overlap']}/5, {a['posts_per_week']} posts/wk") + if r["at_weekly_cap"]: + lines.append(f"\nAt the {MAX_PER_ACCOUNT_PER_WEEK}/week cap: {', '.join(r['at_weekly_cap'])}") + if r["findings"]: + lines.append("\nFindings:") + for f in r["findings"]: + lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}") + lines.append(f" fix → {f['fix']}") + lines.append("\nComment rules:") + for c in r["comment_rules"]: + lines.append(f" - {c}") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Build a weekly LinkedIn commenting roster " + "(roster=0 / below-budget=2 / no-accounts=3). Nothing is posted.") + ap.add_argument("--account", action="append", default=[], + help="name:overlap(1-5):posts_per_week:tier " + "(tier = huge|larger|peer|smaller). Repeatable.") + ap.add_argument("--minutes-per-day", type=int, default=18, + help=f"Minutes per day for commenting (default 18 = " + f"{18 // MINUTES_PER_COMMENT} comments).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Build a roster from sample accounts.") + args = ap.parse_args() + + specs = SAMPLE_ACCOUNTS if args.sample else args.account + minutes = 18 if args.sample else args.minutes_per_day + try: + accounts = [parse_account(s) for s in specs] + except ValueError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 4 + + result = build(accounts, minutes) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_message_builder.py b/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_message_builder.py new file mode 100644 index 000000000..700666c61 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_message_builder.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""outreach_message_builder.py — assemble one LinkedIn message, and refuse the template. + +Cold outreach on LinkedIn fails for one structural reason: the message could have +been sent to anyone. This tool will not assemble a message without a +person-specific line — something you could only have written after actually +looking at what this person did — and it refuses an ask on a first-touch +connection note, because the note is for getting into the room, not for selling +in the doorway. + +Caps enforced (🟡 third-party-documented; LinkedIn does not publish a limits page): + connection note 200 characters (free) / 300 (Premium) — use --premium + DM / InMail no practical cap, but this tool warns past 600 characters + because reply rate falls off a cliff on a phone screen + +Nothing is sent. The output is text you paste, one person at a time. That is not +a limitation of the tool; automated sending is prohibited by LinkedIn's User +Agreement §8.2 and is the fastest route to a restricted account. + +Exit codes: + 0 PASS — assembled and clean + 2 WARN — assembled, with findings worth fixing + 3 FAIL — refused: missing the person-specific line, over the cap, or pitching too early + +Stdlib only. No network. Nothing is transmitted anywhere. +""" + +import argparse +import json +import re +import sys + +CAPS = {"connection": {"free": 200, "premium": 300}, + "dm": {"free": 1800, "premium": 1800}, + "inmail": {"free": 1900, "premium": 1900}, + "followup": {"free": 1200, "premium": 1200}} +DM_COMFORT = 600 + +DEAD_PHRASES = [ + ("i came across your profile", "It tells them nothing. Say what you were reading when you found them."), + ("i'd love to pick your brain", "It asks for unpaid time with no bounded question."), + ("i would love to pick your brain", "It asks for unpaid time with no bounded question."), + ("hope this finds you well", "Filler. Delete it and start with the specific line."), + ("hope you're doing well", "Filler. Delete it and start with the specific line."), + ("quick question", "It is never quick, and everyone knows it."), + ("just following up", "Say what changed since last time, or do not follow up."), + ("touch base", "Says nothing about what you want."), + ("synergy", "Nobody has ever replied to this word."), + ("i'll keep it short", "Then keep it short instead of announcing it."), + ("as a fellow", "Category membership is not a reason to connect."), + ("i see we're both in", "So are two million other people."), + ("let's connect", "A request with no reason attached."), + ("15 minutes of your time", "Fine as a second-touch ask; never in a first-touch note."), +] + +PITCH_RE = re.compile( + r"\b(our (product|platform|solution|service|tool)|we help companies|book a (call|demo)|" + r"schedule a (call|demo)|are you the right person|decision[- ]maker|" + r"i'?d like to show you|free trial|pricing|proposal)\b", re.I) + +ASK_RE = re.compile(r"\b(call|chat|meeting|demo|coffee|zoom|15 min|30 min|hop on|jump on)\b", re.I) + +SAMPLE = { + "type": "connection", + "recipient": "Priya", + "specific_line": "Your teardown of the dbt exposures rollout matched what broke for us at " + "step three.", + "reason": "I am working the same problem from the platform side and would like to follow " + "what you publish.", + "ask": "", + "premium": False, +} + + +def assemble(parts: dict) -> str: + name = (parts.get("recipient") or "").strip() + specific = (parts.get("specific_line") or "").strip() + reason = (parts.get("reason") or "").strip() + ask = (parts.get("ask") or "").strip() + chunks = [] + if name: + chunks.append(f"{name} —") + if specific: + chunks.append(specific) + if reason: + chunks.append(reason) + if ask: + chunks.append(ask) + return " ".join(chunks).strip() + + +def validate(text: str, parts: dict, mtype: str, premium: bool) -> list: + findings = [] + low = text.lower() + + def add(sev, check, finding, fix): + findings.append({"severity": sev, "check": check, "finding": finding, "fix": fix}) + + specific = (parts.get("specific_line") or "").strip() + if not specific: + add("blocking", "person-specific-line", + "No person-specific line. Without it this is a template, and a template is the " + "thing being ignored.", + "Read one thing they published and quote the part you disagreed with or used. " + "If you will not spend three minutes reading, do not spend theirs.") + elif len(specific.split()) < 6: + add("major", "person-specific-line", + f"The specific line is {len(specific.split())} words — too short to prove you read " + "anything.", + "Name the artifact and the part of it that mattered.") + elif not re.search(r"\b(your|you)\b", specific, re.I): + add("warning", "person-specific-line", + "The specific line does not refer to them at all.", + "Anchor it: 'your post on…', 'the talk you gave at…'.") + + cap = CAPS[mtype]["premium" if premium else "free"] + n = len(text) + if n > cap: + add("blocking", "length", + f"{n} characters — {n - cap} over the {cap}-character cap for a " + f"{mtype} ({'Premium' if premium else 'free'} account).", + "Cut the reason, keep the specific line. The reason can wait for the reply.") + elif mtype in ("dm", "inmail", "followup") and n > DM_COMFORT: + add("warning", "length", + f"{n} characters. Past ~{DM_COMFORT} the message is a wall on a phone and reply " + "rate drops.", + "One specific line, one reason, one bounded ask. Everything else is for the reply.") + + if mtype == "connection": + ask = (parts.get("ask") or "").strip() + if ask: + add("blocking", "premature-ask", + "A connection note carries an ask. The note is for getting into the room; the " + "ask belongs in the conversation after they accept.", + "Move it. A third-party study of ~13M outreach touches found notes barely move " + "acceptance (about 26.4% either way) but roughly double the post-accept reply " + "rate — the note earns the conversation, not the meeting.") + if PITCH_RE.search(low): + add("blocking", "pitch-on-first-touch", + "Pitch language in a first-touch connection note.", + "Delete it. Nobody has ever bought from a connection request, and the request " + "is the only impression you get.") + else: + if PITCH_RE.search(low) and not ASK_RE.search(low): + add("warning", "pitch-without-ask", + "Product language with no clear, bounded ask.", + "Either make the ask explicit and small, or cut the product language.") + + dead = [(p, why) for p, why in DEAD_PHRASES if p in low] + if dead: + add("major", "dead-phrases", + "Phrases that mark this as bulk: " + "; ".join(f"'{p}' — {why}" for p, why in dead), + "Cut each one. What remains is either specific or it is nothing, and nothing is " + "better than bulk.") + + if not (parts.get("reason") or "").strip() and mtype == "connection": + add("warning", "reason", + "No reason to connect stated.", + "One clause: what you want to follow, learn, or compare notes on.") + + if parts.get("recipient") and parts["recipient"].strip().lower() in ( + "there", "team", "sir", "madam", "friend", "connection"): + add("major", "salutation", + f"'{parts['recipient']}' is a placeholder greeting.", + "Use their name, spelled the way they spell it.") + + return findings + + +def build(parts: dict, mtype: str, premium: bool) -> dict: + text = assemble(parts) + findings = validate(text, parts, mtype, premium) + blocking = [f for f in findings if f["severity"] == "blocking"] + verdict, code = (("FAIL", 3) if blocking + else ("WARN", 2) if findings else ("PASS", 0)) + cap = CAPS[mtype]["premium" if premium else "free"] + return { + "verdict": verdict, + "exit_code": code, + "type": mtype, + "premium": premium, + "message": text, + "chars": len(text), + "cap": cap, + "findings": findings, + "send_rule": ("Paste and send this yourself, to this one person. Nothing here is " + "transmitted by the tool, and automating the send would violate " + "LinkedIn's User Agreement §8.2."), + "followup_rule": ("One follow-up, at least a week later, only if you have something new " + "to say. A second follow-up with no new information is the point at " + "which you become the thing you were avoiding."), + } + + +def render_human(r: dict) -> str: + lines = [f"Outreach ({r['type']}): {r['verdict']} " + f"[{r['chars']}/{r['cap']} chars" + f"{', Premium' if r['premium'] else ''}]", + "=" * 56, "", r["message"] or "(nothing assembled)", ""] + if r["findings"]: + lines.append("Findings:") + for f in r["findings"]: + lines.append(f" [{f['severity'].upper():<8}] {f['check']}: {f['finding']}") + lines.append(f" fix → {f['fix']}") + else: + lines.append("No findings.") + lines += ["", r["send_rule"], "", r["followup_rule"]] + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Assemble one LinkedIn outreach message (PASS=0 / WARN=2 / FAIL=3). " + "Nothing is sent.") + ap.add_argument("--type", choices=sorted(CAPS), default="connection") + ap.add_argument("--recipient", help="Their first name.") + ap.add_argument("--specific-line", help="The line only this person could receive. Required.") + ap.add_argument("--reason", help="Why you want the connection.") + ap.add_argument("--ask", default="", help="The ask (never on a first-touch connection note).") + ap.add_argument("--premium", action="store_true", + help="Premium account (300-char connection notes).") + ap.add_argument("--input", help="Read all parts from a JSON file ('-' for stdin).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Build the built-in sample message.") + ap.add_argument("--print-schema", action="store_true", help="Print the JSON shape and exit.") + args = ap.parse_args() + + if args.print_schema: + print(json.dumps(SAMPLE, indent=2)) + return 0 + if args.sample: + parts, mtype, premium = SAMPLE, SAMPLE["type"], SAMPLE["premium"] + elif args.input: + raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + try: + parts = json.loads(raw) + except json.JSONDecodeError as exc: + print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr) + return 4 + mtype = parts.get("type", args.type) + premium = bool(parts.get("premium", args.premium)) + if mtype not in CAPS: + print(f"ERROR: unknown message type '{mtype}'", file=sys.stderr) + return 4 + else: + parts = {"recipient": args.recipient, "specific_line": args.specific_line, + "reason": args.reason, "ask": args.ask} + mtype, premium = args.type, args.premium + if not any(parts.values()): + ap.error("provide --specific-line (or --input / --sample / --print-schema)") + + result = build(parts, mtype, premium) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_volume_guard.py b/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_volume_guard.py new file mode 100644 index 000000000..a7d6c9cec --- /dev/null +++ b/marketing/linkedin/skills/linkedin-engagement/scripts/outreach_volume_guard.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""outreach_volume_guard.py — size manual outreach so it stays manual, and legal. + +Three separate things restrict a LinkedIn account, and volume plans usually trip +at least one: + + 1. LinkedIn enforces a weekly invitation limit (widely observed at about 100 + invitations per week; LinkedIn does not publish the exact figure and adjusts + it per account). Pending invitations count. + 2. A low acceptance rate is itself a signal. Sustained low acceptance plus high + volume is the pattern automated tools produce, and it is what gets reviewed. + 3. Anything that looks machine-paced — a constant per-day rate, hundreds of + touches, identical wording — is prohibited outright by User Agreement §8.2, + whether or not a machine actually sent it. + +This tool prices the plan in minutes, checks it against the caps, and refuses the +volumes that cannot be produced by a person typing. It never sends anything. + +Exit codes: + 0 SAFE — plan fits the caps and the time budget + 2 TIGHT — allowed, with named risks + 3 OVER — over a cap or over the time budget; the overage is named + 4 REFUSED — the volume is not humanly manual; this is an automation plan + +Stdlib only. No network. Nothing is transmitted anywhere. +""" + +import argparse +import json +import sys + +WEEKLY_INVITE_LIMIT = 100 # observed LinkedIn cap; not published exactly +DAILY_MANUAL_CEILING = 25 # above this, per day, it stops being hand-written +REFUSE_DAILY = 40 # above this it is an automation plan, not a cadence +LOW_ACCEPTANCE = 0.20 +MINUTES_PER_INVITE = 5 # read their work, write the specific line, send +MINUTES_PER_DM = 8 + + +def guard(invites: int, pending: int, dms: int, minutes: int, + acceptance: float, days_active: int) -> dict: + findings = [] + per_day = invites / max(1, days_active) + + def add(sev, area, finding, fix): + findings.append({"severity": sev, "area": area, "finding": finding, "fix": fix}) + + verdict, code = "SAFE", 0 + + if per_day > REFUSE_DAILY: + add("refusal", "automation-pattern", + f"{invites} invitations over {days_active} day(s) is {per_day:.0f}/day. Nobody reads " + "that many profiles and writes that many specific lines. This is an automation plan.", + "Cut to a volume you would actually type. If the number is the point rather than the " + "people, the channel is wrong — that is what advertising is for.") + verdict, code = "REFUSED", 4 + + total_pipeline = invites + pending + if total_pipeline > WEEKLY_INVITE_LIMIT: + add("blocking", "invite-limit", + f"{invites} new + {pending} pending = {total_pipeline} against a weekly limit observed " + f"around {WEEKLY_INVITE_LIMIT}. LinkedIn counts pending invitations, and it does not " + "publish the exact figure.", + f"Withdraw invitations older than three weeks first, then send at most " + f"{max(0, WEEKLY_INVITE_LIMIT - pending)} this week. Note that a withdrawn invitation " + "cannot be re-sent to the same person for about three weeks.") + if code < 3: + verdict, code = "OVER", 3 + elif total_pipeline > WEEKLY_INVITE_LIMIT * 0.8: + add("warning", "invite-limit", + f"{total_pipeline} of ~{WEEKLY_INVITE_LIMIT} weekly capacity used, counting " + f"{pending} pending.", + "Clear stale pending invitations before the next batch.") + if code == 0: + verdict, code = "TIGHT", 2 + + if per_day > DAILY_MANUAL_CEILING and code < 4: + add("warning", "pacing", + f"{per_day:.0f} invitations/day is above the {DAILY_MANUAL_CEILING} that a person " + "can send with a genuinely specific line each.", + "Spread it. Uneven, human pacing is also what a real person's activity looks like.") + if code == 0: + verdict, code = "TIGHT", 2 + + if 0 < acceptance < LOW_ACCEPTANCE: + add("blocking", "acceptance-rate", + f"{acceptance:.0%} acceptance is below the {LOW_ACCEPTANCE:.0%} floor. Sustained low " + "acceptance at volume is the signal LinkedIn reviews, and it also means the targeting " + "is wrong.", + "Stop sending. Fix who you are targeting and what the note says before resuming. " + "Rebuild acceptance by warming up first — comment on their posts for two weeks, then " + "send.") + if code < 3: + verdict, code = "OVER", 3 + + time_needed = invites * MINUTES_PER_INVITE + dms * MINUTES_PER_DM + if time_needed > minutes: + add("blocking", "time-budget", + f"{invites} invitations ({MINUTES_PER_INVITE} min each) plus {dms} DMs " + f"({MINUTES_PER_DM} min each) needs {time_needed} min; the budget is {minutes} min.", + f"Either cut to {max(0, (minutes - dms * MINUTES_PER_DM) // MINUTES_PER_INVITE)} " + "invitations, or accept that the shortfall gets paid in generic messages — which is " + "the same as not sending them.") + if code < 3: + verdict, code = "OVER", 3 + + safe_weekly = min(WEEKLY_INVITE_LIMIT - pending, + DAILY_MANUAL_CEILING * days_active, + minutes // MINUTES_PER_INVITE if MINUTES_PER_INVITE else 0) + return { + "verdict": verdict, + "exit_code": code, + "plan": {"invites": invites, "pending": pending, "dms": dms, + "days_active": days_active, "per_day": round(per_day, 1), + "minutes_budget": minutes, "minutes_needed": time_needed, + "acceptance_rate": acceptance}, + "caps": {"weekly_invite_limit_observed": WEEKLY_INVITE_LIMIT, + "daily_manual_ceiling": DAILY_MANUAL_CEILING, + "refuse_above_per_day": REFUSE_DAILY, + "note": "LinkedIn does not publish the exact invitation limit and adjusts it " + "per account. These are conservative working numbers."}, + "safe_volume_this_week": max(0, int(safe_weekly)), + "findings": findings, + "standing_rules": [ + "Send by hand, one at a time. Automated sending is prohibited by User Agreement §8.2 " + "and no volume target is worth a restricted account.", + "Every message carries a line that could only have been written for that person.", + "Track acceptance weekly. Falling acceptance means the targeting is wrong, not that " + "the volume is too low.", + "Warm before you ask: two weeks of genuine comments beats any note.", + ], + } + + +def render_human(r: dict) -> str: + p, c = r["plan"], r["caps"] + lines = [f"Outreach volume: {r['verdict']}", "=" * 52, + f"Plan : {p['invites']} invites (+{p['pending']} pending), {p['dms']} DMs over " + f"{p['days_active']} day(s) = {p['per_day']}/day", + f"Time : {p['minutes_needed']} min needed / {p['minutes_budget']} min budget", + f"Caps : ~{c['weekly_invite_limit_observed']}/week, " + f"{c['daily_manual_ceiling']}/day manual ceiling", + f"Safe this week: {r['safe_volume_this_week']} invitations", ""] + if r["findings"]: + lines.append("Findings:") + for f in r["findings"]: + lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}") + lines.append(f" fix → {f['fix']}") + else: + lines.append("No findings.") + lines.append("\nStanding rules:") + for s in r["standing_rules"]: + lines.append(f" - {s}") + lines.append(f"\n{c['note']}") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Size manual LinkedIn outreach against the caps " + "(safe=0 / tight=2 / over=3 / refused=4). Nothing is sent.") + ap.add_argument("--invites", type=int, default=0, help="New connection invitations planned.") + ap.add_argument("--pending", type=int, default=0, help="Invitations already pending.") + ap.add_argument("--dms", type=int, default=0, help="Direct messages planned.") + ap.add_argument("--minutes", type=int, default=120, help="Minutes budgeted this week.") + ap.add_argument("--acceptance", type=float, default=0.0, + help="Recent acceptance rate as a fraction, e.g. 0.34. 0 = unknown.") + ap.add_argument("--days", type=int, default=5, help="Days you will spread this over.") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Run a built-in over-ambitious plan.") + args = ap.parse_args() + + if args.sample: + invites, pending, dms, minutes, acceptance, days = 150, 40, 20, 120, 0.14, 5 + else: + invites, pending, dms = args.invites, args.pending, args.dms + minutes, acceptance, days = args.minutes, args.acceptance, max(1, args.days) + if invites == 0 and dms == 0: + ap.error("provide --invites and/or --dms (or use --sample)") + if not 0.0 <= acceptance <= 1.0: + ap.error("--acceptance must be a fraction between 0 and 1") + + result = guard(invites, pending, dms, minutes, acceptance, max(1, days)) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-profile/SKILL.md b/marketing/linkedin/skills/linkedin-profile/SKILL.md new file mode 100644 index 000000000..49421b938 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-profile/SKILL.md @@ -0,0 +1,98 @@ +--- +name: linkedin-profile +description: Use when someone wants their LinkedIn profile audited or rewritten — headline, About section, experience bullets, Featured, banner, recommendations — or says "fix my headline", "my profile gets views but nothing happens", "optimize my LinkedIn profile", "what should my About section say". Scores the headline on five dimensions, audits the whole profile 0-100 and ranks fixes by points per hour, and assembles an About section that survives the "…see more" fold. +license: MIT +metadata: + version: 1.0.0 + author: Alireza Rezvani + category: marketing + updated: 2026-08-25 +--- + +# LinkedIn Profile — audit, then rewrite the parts that pay + +A profile is not a CV. A CV is read by someone who already decided to consider you; a +profile is read by someone deciding whether to. Three readers, in descending volume: the +**scanner** (three seconds, from your comment on someone else's post — sees photo and +headline only), the **evaluator** (forty seconds — headline, half the About, Featured), the +**decider** (minutes — everything). Almost all traffic is the scanner. Almost all conversion +is the decider. + +## Workflow + +**1. Audit before rewriting.** Ask them to describe their profile section by section, or +have them fill [`assets/profile_worksheet.md`](assets/profile_worksheet.md), then: + +```bash +python3 scripts/profile_completeness_auditor.py --input profile.json --output human +``` + +Exit 0 STRONG / 2 INCOMPLETE / 3 WEAK. It ranks every gap by **points per hour** and prints +a first-hour plan — usually Featured, Open To, and skills, which cost minutes and recover +real points. Start there, not with the About section they wanted to agonise over. + +**2. Headline next — it is the only string that travels.** It rides along with every comment, +search result, and invitation. + +```bash +python3 scripts/headline_scorer.py --headline "..." --output human +``` + +Five dimensions at 20 points: audience, outcome, proof, searchability, clarity. Exit 0 SHIP +(≥75) / 2 SHARPEN / 3 REWRITE. It also checks the 220-character cap and whether the first +~60 characters — the part that survives in search results and invitation previews — carry +anything. + +Iterate here. Two or three passes is normal; the stop condition is exit 0, or the user +saying they would say it out loud to a peer. + +**3. About section — write to the fold.** LinkedIn collapses it after roughly 265-300 +characters. Collect the five parts from the user, then assemble: + +```bash +python3 scripts/about_section_builder.py --input about.json --output human +``` + +It refuses a fold that cuts mid-sentence, a fold carrying no audience and no proof, a +missing call to action, and anything over 2,600 characters. + +**4. Experience, Featured, recommendations.** Rewrite duty lists as outcomes. Pin one +artifact a stranger could evaluate in sixty seconds. Ask for two specific recommendations by +naming the project and offering a first draft. + +## Rules + +- **Never invent a credential, a metric, or a role.** Every number on a profile is checkable + by someone. If the proof does not exist, the claim is qualitative or it is absent. +- **First person.** A profile in third person reads as a press release someone else wrote. +- **The fold is the section.** Whatever sits above "…see more" is what most readers get. +- **Front-load the headline.** The strongest segment first; everything after is a bonus. +- **Do not fetch anything.** The user describes their own profile; nothing is scraped. + +## Scripts + +| Script | Role | +|---|---| +| [`scripts/headline_scorer.py`](scripts/headline_scorer.py) | Headline 0-100 on audience / outcome / proof / searchability / clarity, plus the 220-char cap and front-load check. | +| [`scripts/profile_completeness_auditor.py`](scripts/profile_completeness_auditor.py) | Whole profile 0-100 across 14 weighted checks; fixes ranked by points per hour with a first-hour plan. | +| [`scripts/about_section_builder.py`](scripts/about_section_builder.py) | Assembles the About section from named parts and refuses a broken fold, a missing CTA, or an over-length section. | + +## References and assets + +- [`references/profile_architecture.md`](references/profile_architecture.md) — what each section is for, and the three readers (7 sources) +- [`references/headline_and_positioning.md`](references/headline_and_positioning.md) — the 220 characters that follow you everywhere (7 sources) + +- [`assets/profile_worksheet.md`](assets/profile_worksheet.md) — fillable section-by-section worksheet +- [`assets/example_profile.json`](assets/example_profile.json) — input shape for the auditor + +## Distinct from + +- **`linkedin-strategy`** — what to post and how often. This is who you are before you post. + If the audience answer is fuzzy, run `positioning_brief.py` there first; the headline + falls out of a good brief in ten minutes. +- **`product-team/`, `c-level-advisor/`** — career and role strategy. This writes the profile, + not the career plan. + +--- + +**Version:** 1.0.0 diff --git a/marketing/linkedin/skills/linkedin-profile/assets/example_profile.json b/marketing/linkedin/skills/linkedin-profile/assets/example_profile.json new file mode 100644 index 000000000..b0b52d3f2 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-profile/assets/example_profile.json @@ -0,0 +1,25 @@ +{ + "_comment": "Input shape for profile_completeness_auditor.py. Every key is optional; a missing key is treated as absent. Nothing here is fetched from LinkedIn - you fill it in from your own profile.", + "photo": true, + "banner_custom": true, + "custom_url": true, + "headline": "Fractional Head of Data for Series A SaaS | Cut BigQuery spend 62% at Zendesk scale | ex-Stripe | I make dashboards people trust", + "about": "Most data teams do not have a data problem. They have a trust problem - nobody believes the dashboard, so everyone rebuilds the number in a spreadsheet. I work with Series A SaaS companies whose analytics stack grew faster than anyone's confidence in it. Cut BigQuery spend 62% at Zendesk scale without dropping a single dashboard. Rebuilt reporting for three Series B teams; two retired their shadow spreadsheets inside a quarter. If your team is re-deriving the same metric in three places, message me.", + "current_role": { + "title": "Fractional Head of Data", + "bullets": [ + "Cut BigQuery spend 62% while keeping every dashboard live", + "Reduced median time-to-trusted-number from 9 days to under 1", + "Led the migration of 140 legacy models down to 38" + ] + }, + "featured_items": 2, + "featured_updated_days_ago": 45, + "skills": ["analytics engineering", "dbt", "BigQuery", "data governance", "SQL", "Python"], + "recommendations_received": 3, + "open_to_or_services": true, + "education": true, + "certifications": 1, + "contact_info": true, + "days_since_last_post": 6 +} diff --git a/marketing/linkedin/skills/linkedin-profile/assets/profile_worksheet.md b/marketing/linkedin/skills/linkedin-profile/assets/profile_worksheet.md new file mode 100644 index 000000000..5d07bab23 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-profile/assets/profile_worksheet.md @@ -0,0 +1,73 @@ +# Profile Worksheet + +Fill this in before any rewriting. The auditor and the About builder both read from it. + +--- + +## Presence + +- [ ] Photo: face, well lit, recognisable at 48px? ______ +- [ ] Banner: custom, or still the default gradient? ______ +- [ ] Custom URL set? ______ +- [ ] Contact info reachable? ______ +- [ ] Open To / Services block set? ______ +- [ ] Days since your last post: ______ + +## Headline (220 characters) + +Current: ______________________________________________ + +- Who is it for (specifically enough to exclude someone)? ______ +- What changes because of you? ______ +- One piece of proof (number / ex-company / credential)? ______ +- Which conventional role or skill term keeps it searchable? ______ + +First 60 characters — is the strongest segment there? ______ + +## About (fold at ~265-300 characters) + +1. **Hook** — the tension your audience recognises, in their words: + +2. **Audience** — who you are for: + +3. **Proof** — two or three real results, with numbers: + +4. **Approach** — how you work; the part that is yours, not your title's: + +5. **CTA** — who should reach out, and what they get: + +6. **Keywords** you want to be findable for (work them into real sentences, not a list): + +## Current role + +Title: ______ + +Rewrite each bullet as an outcome, not a duty: + +| Duty (what it says now) | Outcome (what actually changed) | +|---|---| +| | | +| | | + +## Featured + +- What is pinned now? ______ +- Last updated? ______ +- What could a stranger evaluate in 60 seconds? ______ + +## Recommendations + +- Received: ______ +- Two people to ask, and the specific project to ask them about: + 1. ______ + 2. ______ + +--- + +**Then run:** + +```bash +python3 ../scripts/profile_completeness_auditor.py --input profile.json --output human +python3 ../scripts/headline_scorer.py --headline "..." --output human +python3 ../scripts/about_section_builder.py --input about.json --output human +``` diff --git a/marketing/linkedin/skills/linkedin-profile/references/headline_and_positioning.md b/marketing/linkedin/skills/linkedin-profile/references/headline_and_positioning.md new file mode 100644 index 000000000..658cadb3c --- /dev/null +++ b/marketing/linkedin/skills/linkedin-profile/references/headline_and_positioning.md @@ -0,0 +1,128 @@ +# Headline and Positioning — 220 characters that follow you everywhere + +The headline is the only string on LinkedIn that travels with you. It rides along +with every comment you leave in someone else's thread, every search result you +appear in, and every connection request you send. A person may see your headline +forty times before they ever open your profile. + +Most headlines are a job title. A job title is the one thing a reader could have +guessed. + +--- + +## The constraint + +| Limit | Value | Confidence | +|---|---|---| +| Hard character cap | 220 | 🟡 third-party documented, stable for years | +| Visible in search results / invitation previews | ~60-70 characters | 🟡 varies by surface and viewport | +| Visible next to a comment | Fewer still, and it truncates mid-word | 🟡 | + +So the headline has a **front-loaded budget**: the first sixty characters do most +of the work, and everything after them is a bonus that some readers will see. +This is why "Senior Engineer | Ex-Google | Speaker | Mentor | Dog dad" fails — +the strongest segment is second and the weakest is where a reader's eye lands. + +## The five things a headline has to do + +`headline_scorer.py` scores these at 20 points each. + +1. **Name the audience.** "for Series A SaaS founders", "for clinical data teams". + A headline that could belong to anyone is addressed to no one. +2. **Name the outcome.** What changes because of you. "Cut onboarding from six + weeks to four days" is an outcome. "Passionate about customer experience" is + a mood. +3. **Carry one piece of proof.** A number, a prior company (`ex-Stripe`), a + credential, a scale figure. One is enough and it has to be true. +4. **Stay searchable.** LinkedIn search matches headline text. An invented title + ("Chief Clarity Officer") ranks for nothing. Keep at least one conventional + role or skill term alongside the creative framing. +5. **Stay readable.** Three segments maximum. At most one emoji. No buzzword + filler — "results-driven", "passionate about", "thought leader" all describe an + attitude rather than a capability, and every reader has learned to skip them. + +## Structures that work + +``` +[Role a recruiter would search] for [specific audience] | [proof] | [the line that is yours] + +Fractional Head of Data for Series A SaaS | Cut BigQuery spend 62% at +Zendesk scale | I make dashboards people trust +``` + +``` +I help [audience] [outcome] without [the cost they expect] + +I help clinical teams pass MDR audits without a six-month documentation freeze +``` + +``` +[Current title] · [the thing you are moving toward] · [proof of the move] + +Backend engineer moving into developer advocacy · 40 conference talks +watched, 3 given · I write the docs I wish existed +``` + +That third pattern matters for career transitions. **State the destination, not +only the origin.** A headline that describes only where you have been makes every +reader do the imaginative work of placing you somewhere else, and most will not +bother. + +## Positioning before wording + +A headline cannot fix a positioning that does not exist. If you cannot answer +these three, the headline will keep coming out generic no matter how many times +it is rewritten: + +1. Who specifically is this for — specifically enough that someone is excluded? +2. What do they get that they would not get from the next person with your title? +3. What is the evidence, and is it already public? + +That is the `positioning_brief.py` conversation in `linkedin-strategy`. Run it +first when the answers are fuzzy; the headline falls out of a good brief in +about ten minutes. + +## Changing the headline mid-career-transition + +Two competing risks, and people usually only see one: + +- Change too early and current colleagues read it as "already leaving". +- Change too late and every new reader files you under the old category, which + is the category you are trying to leave. + +The resolution is that **the headline is read overwhelmingly by strangers**, and +strangers are the audience of a transition. Change it, and let the current role +carry the continuity in the Experience section where it belongs. + +## Testing + +You cannot A/B test a headline on LinkedIn — there is one, it applies +retroactively to everything, and profile-view counts are too noisy at individual +scale. What you can do: + +- Read it out loud. If you would not say it to someone at a conference, cut it. +- Show it to one person in the target audience and ask what they think you do. + If they paraphrase it back wrong, the headline is wrong, and that single test + is worth more than any tool. +- Run `headline_scorer.py` for the mechanical faults, then use the person. + +--- + +## Sources + +1. Ries, A. & Trout, J. **Positioning: The Battle for Your Mind** — the origin of + "positioning is what you do to the mind of the prospect", and why a category + claim beats an attribute list. +2. Heath, C. & Heath, D. **Made to Stick** — concreteness and the curse of + knowledge, which is exactly why practitioners write headlines only other + practitioners can parse. +3. Nielsen Norman Group. **Microcontent: How to Write Headlines, Page Titles, and + Subject Lines** — the front-loading rule for any truncated string. +4. Clark, D. **Stand Out** — building a positioning around one distinctive idea + rather than a portfolio of competences. +5. Ibarra, H. **Working Identity** — why career transitions require acting into + the new identity publicly rather than deciding it privately first. +6. Zinsser, W. **On Writing Well** — the discipline of cutting every word that + does no work, which is the entire craft of a 220-character string. +7. LinkedIn Help. **Search and profile visibility** documentation — headline text + is matched in search; invented titles are not. diff --git a/marketing/linkedin/skills/linkedin-profile/references/profile_architecture.md b/marketing/linkedin/skills/linkedin-profile/references/profile_architecture.md new file mode 100644 index 000000000..b6b08f484 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-profile/references/profile_architecture.md @@ -0,0 +1,124 @@ +# Profile Architecture — what each section is actually for + +A LinkedIn profile is not a CV. A CV is read by someone who has already decided +to consider you; a profile is read by someone deciding whether to. The sections +are therefore ordered by a different logic, and most people fill them in as if +they were the same document. + +The frame that makes this concrete: **a profile has three distinct readers**, and +a section that serves none of them is dead weight. + +| Reader | Arrives from | Decides in | Reads | +|---|---|---|---| +| **The scanner** | Your comment on someone else's post | ~3 seconds | Photo, name, headline. Nothing else. | +| **The evaluator** | A search result, a referral, your post | ~40 seconds | Headline, the visible half of About, Featured, current role. | +| **The decider** | Already interested, doing diligence | Several minutes | Everything, including recommendations and the gaps in your history. | + +Almost all traffic is the scanner. Almost all conversion is the decider. The +sections in between exist to move people down that list. + +--- + +## Section by section + +### Photo — for the scanner +A face, well lit, roughly filling the frame, recognisable at 48 pixels. That is +the entire specification. The common failure is a full-body shot that renders as +a smudge at feed size. + +### Banner (1584 × 396) — the most wasted space on the platform +The default gradient says nothing. This is the cheapest place to state what you +do, name your audience, or show one artifact. Rendering caveat: the profile photo +and the mobile layout crop the lower-left corner, so keep text in the upper right +and never centre it. + +### Headline — the only string that travels +It appears next to every comment you leave, in every search result, in every +invitation you send. See `headline_and_positioning.md`; this is the single +highest-leverage edit available. + +### About — for the evaluator, and it is truncated +LinkedIn collapses it after roughly the first 265-300 characters. Whatever is +above that fold is the whole section for most readers. Structure that survives: + +1. **The tension** — the problem your audience recognises, stated in their words. +2. **Who you are for** — specific enough that someone could be excluded. +3. **Proof** — two or three results, with real numbers. +4. **How you work** — the part that is yours rather than your job title's. +5. **A call to action** — who should reach out, and what they get. + +Written in first person. A profile written in the third person reads as a press +release someone else wrote, which on a personal profile is exactly what it is. + +### Experience — outcomes, not duties +A duty list is interchangeable across everyone who has ever held the title. +"Responsible for backend services" describes several hundred thousand people. +"Cut p99 checkout latency from 1.9s to 340ms by moving the pricing call +off the critical path" describes one. + +Two or three bullets per role, and only the roles that support the current +positioning. Older roles get a line each. Gaps are fine and unexplained gaps are +also fine — most readers do not care, and the ones who do will ask. + +### Featured — the one section you fully control +Whatever you pin here is what a visitor sees before your feed. Empty, and they +see whatever you last reposted. Put the artifact a buyer, hiring manager, or peer +could evaluate in sixty seconds: the talk, the repo, the teardown, the post that +did what you want more of. + +Refresh it quarterly. A featured item from three years ago dates the whole +profile. + +### Skills — a matching surface, not a personality test +Recruiters and LinkedIn's own matching use these. List the ones you would accept +an interview on. The endorsement counts matter far less than people assume; the +presence of the term matters more. + +### Recommendations — the only text you did not write +Two specific recommendations outperform ten generic ones. The way to get a +specific one is to ask for a specific one: tell the person the project and the +aspect you want them to speak to, and offer to write a first draft they can edit. +That request is normal and almost always accepted. + +### Custom URL, Open To / Services, contact info — two-minute fixes +Low individual value, near-zero cost, and their absence signals a profile nobody +maintains. + +--- + +## The ordering rule + +When time is short, work in this order, because it is the order in which readers +encounter the sections and drop out: + +1. Headline (every reader, always) +2. Photo (every reader, three seconds) +3. About opening — the first two sentences only +4. Featured (one item, today) +5. Current role bullets +6. Everything else + +`profile_completeness_auditor.py` implements this as points-per-hour and will +usually put Featured and Open To first, because they cost minutes and recover +real points. + +--- + +## Sources + +1. Nielsen Norman Group. **F-Shaped Pattern for Reading Web Content** and + **"How People Read Online"** — why the first lines carry the decision and the + rest is skimmed. +2. Krug, S. **Don't Make Me Think** (3rd ed.) — scannability as the design + constraint for anything a stranger reads without commitment. +3. Minto, B. **The Pyramid Principle** — the answer first, support after; the + structure the About fold forces on you whether you like it or not. +4. Goffman, E. **The Presentation of Self in Everyday Life** (1959) — a profile + is a front-stage performance with an audience that knows it is one; the + credibility comes from specificity, not polish. +5. Heath, C. & Heath, D. **Made to Stick** — concreteness as the property that + makes a claim memorable and checkable. +6. LinkedIn Help. **Creators Core / Updates to Creator Mode** — what the creator + surfaces actually change on a profile. +7. Clark, D. **Reinventing You** — sequencing a positioning change so the public + record supports the new claim before you make it. diff --git a/marketing/linkedin/skills/linkedin-profile/scripts/about_section_builder.py b/marketing/linkedin/skills/linkedin-profile/scripts/about_section_builder.py new file mode 100644 index 000000000..cb9d14261 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-profile/scripts/about_section_builder.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""about_section_builder.py — assemble a LinkedIn About section that survives the fold. + +LinkedIn collapses the About section after roughly the first 265-300 characters and +hides the rest behind "…see more". Most people spend that window on a warm-up +sentence, so the only part a stranger reads is the part that says nothing. + +This tool assembles the section from named parts, then refuses the ones that break: + - anything past the 2,600-character cap + - a fold window that does not end on a sentence boundary (a truncated word is the + reader's cue to stop) + - a fold window carrying no audience and no proof + - buzzword filler + - a missing call to action (a profile that converts needs a next step) + +It does not invent claims. Every part comes from you; the tool only orders, +measures, and refuses. + +Exit codes: + 0 PASS — assembled and clean + 2 WARN — assembled, with non-blocking findings + 3 FAIL — blocking findings; fix and re-run before publishing + +Stdlib only. No network. Deterministic. +""" + +import argparse +import json +import re +import sys + +HARD_LIMIT = 2600 +FOLD_SAFE = 265 # conservative end of the observed 265-300 truncation window +FOLD_OBSERVED = 300 + +BUZZWORDS = [ + "passionate about", "results-driven", "results driven", "detail-oriented", + "detail oriented", "self-starter", "team player", "hard worker", "guru", + "ninja", "rockstar", "thought leader", "seasoned professional", "dynamic", + "proven track record", "wear many hats", "think outside the box", + "synergy", "leverage my skills", "world-class", "cutting-edge", +] + +PROOF_RE = re.compile( + r"\d+\s*(%|x\b|×)|[$€£]\s?\d|\b\d+\s*(k|m|bn)\b|\bex-[A-Z]|" + r"\b\d{1,3}(,\d{3})+\b|\b\d+\s*(users|customers|clients|teams|engineers|" + r"subscribers|downloads|countries|years)\b", re.I) + +AUDIENCE_RE = re.compile( + r"\b(for|helping|i help|we help|i work with|my clients|founders|ctos?|cmos?|" + r"engineers|designers|marketers|recruiters|startups|smbs|teams|operators|" + r"clinicians|students|investors|product managers|data teams)\b", re.I) + +SAMPLE = { + "hook": "Most data teams do not have a data problem. They have a trust problem — " + "nobody believes the dashboard, so everyone rebuilds the number in a spreadsheet.", + "audience": "I work with Series A SaaS companies whose analytics stack grew faster than " + "anyone's confidence in it.", + "proof": [ + "Cut BigQuery spend 62% at Zendesk scale without dropping a single dashboard.", + "Rebuilt reporting for three Series B teams; two of them retired their shadow spreadsheets inside a quarter.", + ], + "approach": "I start by finding the number people actually argue about, then work backwards " + "through the dbt models to the BigQuery tables that produce it. Usually the fix " + "is fewer models and clearer ownership, not more tooling — analytics engineering " + "is a data governance problem wearing a modelling costume.", + "cta": "If your team is re-deriving the same metric in three places, message me — I will tell " + "you in one call whether it is a modelling problem or an ownership problem.", + "keywords": ["analytics engineering", "dbt", "BigQuery", "data governance", "Series A SaaS"], +} + +ORDER = ["hook", "audience", "proof", "approach", "cta"] + + +def assemble(parts: dict) -> str: + blocks = [] + for key in ORDER: + val = parts.get(key) + if not val: + continue + if isinstance(val, list): + blocks.append("\n".join(f"— {v.strip()}" for v in val if v.strip())) + else: + blocks.append(str(val).strip()) + body = "\n\n".join(b for b in blocks if b) + kws = [k for k in (parts.get("keywords") or []) if k.strip()] + if kws: + body += "\n\nAreas I work in: " + " · ".join(k.strip() for k in kws) + return body + + +def _fold_window(text: str) -> tuple: + """Return (window, ends_cleanly, boundary_index).""" + window = text[:FOLD_SAFE] + if len(text) <= FOLD_SAFE: + return window, True, len(text) + # Last sentence-ending punctuation inside the safe window. + boundary = max(window.rfind("."), window.rfind("!"), window.rfind("?")) + return window, boundary >= FOLD_SAFE - 90, boundary + + +def validate(text: str, parts: dict) -> list: + findings = [] + n = len(text) + if n > HARD_LIMIT: + findings.append({ + "severity": "blocking", "check": "length", + "finding": f"{n} characters — {n - HARD_LIMIT} over the {HARD_LIMIT} cap. " + "LinkedIn will truncate or refuse it.", + "fix": "Cut the approach block first; it is the part a reader can infer.", + }) + if n < 400: + findings.append({ + "severity": "warning", "check": "length", + "finding": f"{n} characters. Under ~400 there is not room for both a claim and its proof.", + "fix": "Add one concrete result with a number, or one sentence about who you are for.", + }) + + window, clean, boundary = _fold_window(text) + if not clean: + findings.append({ + "severity": "blocking", "check": "fold", + "finding": f"The visible window (first ~{FOLD_SAFE} chars, truncation observed at " + f"{FOLD_SAFE}-{FOLD_OBSERVED}) cuts mid-sentence. A reader sees a broken " + "thought and stops.", + "fix": f"Rewrite the opening so a sentence ends between character " + f"{FOLD_SAFE - 90} and {FOLD_SAFE}.", + }) + if not AUDIENCE_RE.search(window) and not PROOF_RE.search(window): + findings.append({ + "severity": "blocking", "check": "fold-content", + "finding": "The visible window names no audience and carries no proof — it is a " + "warm-up, and the warm-up is all most readers get.", + "fix": "Move the sentence that names who you are for, or the one with the number, " + "into the first two sentences.", + }) + + low = text.lower() + hits = [b for b in BUZZWORDS if b in low] + if hits: + findings.append({ + "severity": "warning", "check": "buzzwords", + "finding": f"Filler present: {', '.join(hits)}.", + "fix": "Delete each one. If deleting it removes meaning, replace it with the " + "specific thing it was standing in for.", + }) + + if not parts.get("cta"): + findings.append({ + "severity": "blocking", "check": "cta", + "finding": "No call to action. The section ends and the reader has nothing to do.", + "fix": "One line naming who should get in touch and what they will get from it.", + }) + + if not PROOF_RE.search(text): + findings.append({ + "severity": "warning", "check": "proof", + "finding": "No number, prior company, or scale signal anywhere in the section.", + "fix": "One real, checkable data point. A range is fine; an invented figure is not.", + }) + + first_person = len(re.findall(r"\bI\b|\bmy\b|\bme\b", text)) + if first_person < 2: + findings.append({ + "severity": "warning", "check": "voice", + "finding": "Written in third person or with no first-person voice. On a personal " + "profile that reads as a press release someone else wrote.", + "fix": "Write it as you would say it: 'I work with…', not 'Alex is a…'.", + }) + + kws = [k.strip().lower() for k in (parts.get("keywords") or []) if k.strip()] + # Check against the prose only — the trailing "Areas I work in" list would + # otherwise satisfy every keyword and make this check vacuous. + prose = low.split("\n\nareas i work in:")[0] + missing = [k for k in kws if k not in prose] + if missing: + findings.append({ + "severity": "warning", "check": "keywords", + "finding": f"Declared keywords not present in the body: {', '.join(missing)} " + "(they appear only in the trailing list).", + "fix": "Work the important ones into a real sentence. A keyword list at the bottom " + "is weaker than the same term used in context.", + }) + return findings + + +def build(parts: dict) -> dict: + text = assemble(parts) + findings = validate(text, parts) + blocking = [f for f in findings if f["severity"] == "blocking"] + warnings = [f for f in findings if f["severity"] == "warning"] + verdict, code = (("FAIL", 3) if blocking else ("WARN", 2) if warnings else ("PASS", 0)) + window, clean, _ = _fold_window(text) + return { + "verdict": verdict, + "exit_code": code, + "about": text, + "chars": len(text), + "limit": HARD_LIMIT, + "visible_before_see_more": window, + "fold_ends_on_sentence": clean, + "findings": blocking + warnings, + "note": ("The 2,600-character cap and the ~265-300 truncation window are " + "third-party-documented, not published by LinkedIn. The tool enforces the " + "conservative end of the window on purpose."), + } + + +def render_human(r: dict) -> str: + lines = [f"About section: {r['verdict']} ({r['chars']}/{r['limit']} chars)", "=" * 56, + "", "VISIBLE BEFORE \"…see more\":", + f" {r['visible_before_see_more']}", + f" [ends on a sentence: {'yes' if r['fold_ends_on_sentence'] else 'NO'}]", ""] + if r["findings"]: + lines.append("Findings:") + for f in r["findings"]: + lines.append(f" [{f['severity'].upper():<8}] {f['check']}: {f['finding']}") + lines.append(f" fix → {f['fix']}") + else: + lines.append("No findings.") + lines += ["", "-" * 56, "FULL SECTION", "-" * 56, r["about"], "", r["note"]] + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Assemble and validate a LinkedIn About section " + "(PASS=0 / WARN=2 / FAIL=3).") + ap.add_argument("--hook", help="Opening line: the tension or observation.") + ap.add_argument("--audience", help="Who you are for, in their words.") + ap.add_argument("--proof", action="append", default=[], + help="A real, checkable result. Repeatable.") + ap.add_argument("--approach", help="How you work — the part that is yours.") + ap.add_argument("--cta", help="Who should reach out and what they get.") + ap.add_argument("--keyword", action="append", default=[], + help="A term you want to be findable for. Repeatable.") + ap.add_argument("--input", help="Read all parts from a JSON file ('-' for stdin).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Build the built-in sample section.") + ap.add_argument("--print-schema", action="store_true", + help="Print the input JSON shape and exit.") + args = ap.parse_args() + + if args.print_schema: + print(json.dumps(SAMPLE, indent=2)) + return 0 + if args.sample: + parts = SAMPLE + elif args.input: + raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + try: + parts = json.loads(raw) + except json.JSONDecodeError as exc: + print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr) + return 4 + else: + parts = {"hook": args.hook, "audience": args.audience, "proof": args.proof, + "approach": args.approach, "cta": args.cta, "keywords": args.keyword} + if not any(parts.values()): + ap.error("provide at least --hook (or use --input / --sample / --print-schema)") + + result = build(parts) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-profile/scripts/headline_scorer.py b/marketing/linkedin/skills/linkedin-profile/scripts/headline_scorer.py new file mode 100644 index 000000000..9c286c128 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-profile/scripts/headline_scorer.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""headline_scorer.py — score a LinkedIn headline 0-100 on the five things it must do. + +The headline is the single highest-leverage string on LinkedIn: it rides along with +every comment you leave, every search result you appear in, and every connection +request you send. Most of them are a job title, which tells a reader nothing they +could not have guessed. + +Five dimensions, 20 points each: + AUDIENCE — does it name who this person is for? + OUTCOME — does it name what changes for that audience? + PROOF — is there a specific, checkable signal (number, company, credential)? + SEARCHABILITY — does it contain role/skill terms a recruiter or buyer would type? + CLARITY — front-loaded, readable, free of buzzword filler, within limits + +Limits enforced (🟡 third-party-documented, LinkedIn does not publish a limits page): + 220 characters hard cap; the first ~60-70 characters are what survive in search + results and invitation previews, so the load-bearing words go there. + +Exit codes: + 0 SHIP (>= 75) — good enough to publish + 2 SHARPEN (50-74) — usable, but named gaps cost real visibility + 3 REWRITE (< 50) — start over; the findings say what is missing + +Stdlib only. No network. Deterministic. +""" + +import argparse +import json +import re +import sys + +HARD_LIMIT = 220 +FRONT_LOAD = 60 # characters that survive in search results / invite previews + +# Filler that reads as self-description rather than evidence. +BUZZWORDS = [ + "guru", "ninja", "rockstar", "wizard", "evangelist", "thought leader", + "visionary", "passionate about", "results-driven", "results driven", + "detail-oriented", "detail oriented", "self-starter", "go-getter", + "dynamic", "synergy", "disruptor", "innovator", "serial entrepreneur", + "world-class", "world class", "best-in-class", "growth hacker", + "seasoned", "proven track record", "hard worker", "team player", +] + +# Words that signal an audience is being named. +AUDIENCE_MARKERS = [ + "for ", "helping", "i help", "we help", "to ", "founders", "ctos", "cto", + "cmos", "engineers", "designers", "marketers", "recruiters", "startups", + "smbs", "smes", "enterprises", "teams", "b2b", "b2c", "saas", "agencies", + "nonprofits", "students", "clinicians", "operators", "pms", "product managers", + "developers", "data teams", "hr", "sales teams", "investors", +] + +# Words that signal an outcome / transformation rather than a job description. +OUTCOME_MARKERS = [ + "ship", "grow", "scale", "reduce", "cut", "increase", "double", "win", + "hire", "raise", "launch", "fix", "unblock", "automate", "migrate", + "build", "turn", "convert", "retain", "save", "speed", "faster", + "without", "so they", "so you", "so that", "→", "->", "from ", "into ", +] + +# Terms recruiters and buyers actually type into LinkedIn search. +SEARCH_TERMS = [ + "engineer", "developer", "architect", "manager", "director", "head of", + "vp", "founder", "consultant", "designer", "analyst", "scientist", + "marketer", "writer", "researcher", "advisor", "coach", "lead", + "python", "react", "kubernetes", "aws", "gcp", "azure", "sql", "ml", + "ai", "llm", "security", "devops", "sre", "data", "product", "ux", + "seo", "content", "finance", "legal", "clinical", "regulatory", + "recruiter", "sales", "customer success", "operations", "platform", +] + +SAMPLE_GOOD = ("Fractional Head of Data for Series A/B SaaS | Cut BigQuery spend 62% at " + "Zendesk scale | ex-Stripe | I make dashboards people trust") +SAMPLE_WEAK = "Senior Software Engineer | Passionate about technology | Team player" + + +def _find(text_low: str, needles: list) -> list: + return [n for n in needles if n in text_low] + + +def _has_proof(text: str) -> list: + """Specific checkable signals: numbers, %/$, ex-Company, named credentials.""" + signals = [] + if re.search(r"\d+\s*(%|x\b|×)", text): + signals.append("percentage or multiple") + if re.search(r"[$€£]\s?\d", text) or re.search(r"\d+\s?(k|m|bn|b)\b", text, re.I): + signals.append("money or magnitude") + if re.search(r"\bex-[A-Z][\w&.-]+", text): + signals.append("prior company (ex-)") + if re.search(r"\b(phd|md|mba|cpa|pmp|cissp|cfa|rn|jd)\b", text, re.I): + signals.append("credential") + if re.search(r"\b(author|speaker|patent|award|forbes|ycombinator|y combinator|" + r"techstars|open[- ]source maintainer)\b", text, re.I): + signals.append("third-party proof") + if re.search(r"\b\d{1,3}(,\d{3})+\b|\b\d+\s*(users|customers|clients|teams|" + r"engineers|downloads|subscribers)\b", text, re.I): + signals.append("scale number") + return signals + + +def score_headline(text: str) -> dict: + raw = text.strip() + low = raw.lower() + findings, dims = [], {} + + # --- AUDIENCE ----------------------------------------------------------- + aud = _find(low, AUDIENCE_MARKERS) + dims["audience"] = 20 if len(aud) >= 2 else (12 if aud else 0) + if not aud: + findings.append({ + "severity": "blocking", "dimension": "audience", + "finding": "No audience named. A reader cannot tell whether this person is for them.", + "fix": "Name the group in plain words: 'for Series A SaaS founders', 'for clinical data teams'.", + }) + + # --- OUTCOME ------------------------------------------------------------ + out = _find(low, OUTCOME_MARKERS) + dims["outcome"] = 20 if len(out) >= 2 else (12 if out else 0) + if not out: + findings.append({ + "severity": "blocking", "dimension": "outcome", + "finding": "States a role, not a result. Titles are interchangeable; outcomes are not.", + "fix": "Add what changes because of you: 'cut onboarding from 6 weeks to 4 days'.", + }) + + # --- PROOF -------------------------------------------------------------- + proof = _has_proof(raw) + dims["proof"] = 20 if len(proof) >= 2 else (12 if proof else 0) + if not proof: + findings.append({ + "severity": "major", "dimension": "proof", + "finding": "No checkable signal. Every claim here is self-assessed.", + "fix": "One number, one prior company, or one credential. Real, or leave it out.", + }) + + # --- SEARCHABILITY ------------------------------------------------------ + terms = _find(low, SEARCH_TERMS) + dims["searchability"] = 20 if len(terms) >= 3 else (13 if len(terms) == 2 else + (7 if terms else 0)) + if len(terms) < 2: + findings.append({ + "severity": "major", "dimension": "searchability", + "finding": f"Only {len(terms)} recognizable search term(s). " + "LinkedIn search matches headline text; invented job titles do not rank.", + "fix": "Keep at least one conventional role or skill term alongside the creative framing.", + }) + + # --- CLARITY ------------------------------------------------------------ + clarity = 20 + hits = _find(low, BUZZWORDS) + if hits: + clarity -= min(10, 4 * len(hits)) + findings.append({ + "severity": "major", "dimension": "clarity", + "finding": f"Buzzword filler: {', '.join(hits)}. These describe an attitude, not a capability.", + "fix": "Delete them. The space buys you a real number or a real audience.", + }) + pipes = raw.count("|") + raw.count("•") + raw.count("·") + if pipes > 3: + clarity -= 5 + findings.append({ + "severity": "minor", "dimension": "clarity", + "finding": f"{pipes} separators. Past three, it reads as a list of keywords rather than a claim.", + "fix": "Keep three segments: who you help / what changes / one proof.", + }) + emoji = len(re.findall(r"[\U0001F300-\U0001FAFF☀-➿]", raw)) + if emoji > 2: + clarity -= 4 + findings.append({ + "severity": "minor", "dimension": "clarity", + "finding": f"{emoji} emoji. They survive truncation and crowd out words that carry meaning.", + "fix": "At most one, and only if it separates segments.", + }) + caps_words = [w for w in raw.split() if len(w) > 3 and w.isupper()] + if len(caps_words) > 1: + clarity -= 3 + findings.append({ + "severity": "minor", "dimension": "clarity", + "finding": "Multiple ALL-CAPS words read as shouting and hurt scannability.", + "fix": "Sentence case. Emphasis comes from specificity, not capitals.", + }) + dims["clarity"] = max(0, clarity) + + # --- Length + front-loading (structural, reported alongside the score) --- + length = len(raw) + front = raw[:FRONT_LOAD] + over = max(0, length - HARD_LIMIT) + if over: + findings.append({ + "severity": "blocking", "dimension": "length", + "finding": f"{length} characters — {over} over the 220-character cap. LinkedIn will refuse it.", + "fix": f"Cut {over} characters. Start with the segment carrying the least proof.", + }) + front_has_proof = bool(_has_proof(front)) or bool(_find(front.lower(), AUDIENCE_MARKERS)) + if not front_has_proof: + findings.append({ + "severity": "major", "dimension": "front-load", + "finding": f"The first {FRONT_LOAD} characters — the part that survives in search " + f"results and invitation previews — carry no audience and no proof: " + f"\"{front}\"", + "fix": "Move the strongest segment first. Everything after it is a bonus, not a plan.", + }) + + total = sum(dims.values()) + if over: # a headline LinkedIn will not accept cannot ship + total = min(total, 49) + verdict, code = (("SHIP", 0) if total >= 75 else + ("SHARPEN", 2) if total >= 50 else ("REWRITE", 3)) + + return { + "headline": raw, + "score": total, + "verdict": verdict, + "exit_code": code, + "dimensions": dims, + "length": {"chars": length, "limit": HARD_LIMIT, "over_by": over, + "front_loaded_preview": front}, + "signals": {"audience": aud, "outcome": out, "proof": proof, "search_terms": terms}, + "findings": sorted(findings, + key=lambda f: {"blocking": 0, "major": 1, "minor": 2}[f["severity"]]), + "note": ("Character limits are documented by third parties, not by an official LinkedIn " + "limits page — treat 220 as reliable and the ~60-70 front-load window as an " + "estimate that shifts with UI changes."), + } + + +def render_human(r: dict) -> str: + lines = [ + f"Headline score: {r['score']}/100 — {r['verdict']}", + "=" * 52, + f"\"{r['headline']}\"", + f"{r['length']['chars']}/{r['length']['limit']} chars" + + (f" (OVER BY {r['length']['over_by']})" if r["length"]["over_by"] else ""), + f"Search/invite preview: \"{r['length']['front_loaded_preview']}\"", + "", + "Dimensions (20 each):", + ] + for k, v in r["dimensions"].items(): + bar = "#" * (v // 2) + "." * (10 - v // 2) + lines.append(f" {k:<14} {v:>2}/20 [{bar}]") + if r["findings"]: + lines.append("\nFindings:") + for f in r["findings"]: + lines.append(f" [{f['severity'].upper():<8}] {f['dimension']}: {f['finding']}") + lines.append(f" fix → {f['fix']}") + else: + lines.append("\nNo findings. Ship it.") + lines.append(f"\n{r['note']}") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Score a LinkedIn headline 0-100 (SHIP=0 / SHARPEN=2 / REWRITE=3).") + src = ap.add_mutually_exclusive_group() + src.add_argument("--headline", help="The headline text to score.") + src.add_argument("--input", help="Read the headline from a file ('-' for stdin).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", + help="Score a built-in strong sample headline.") + ap.add_argument("--sample-weak", action="store_true", + help="Score a built-in weak sample headline (shows the failure modes).") + args = ap.parse_args() + + if args.sample: + text = SAMPLE_GOOD + elif args.sample_weak: + text = SAMPLE_WEAK + elif args.headline: + text = args.headline + elif args.input: + text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + else: + ap.error("one of --headline, --input, --sample, or --sample-weak is required") + + result = score_headline(text) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-profile/scripts/profile_completeness_auditor.py b/marketing/linkedin/skills/linkedin-profile/scripts/profile_completeness_auditor.py new file mode 100644 index 000000000..2222e4de5 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-profile/scripts/profile_completeness_auditor.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""profile_completeness_auditor.py — score a LinkedIn profile 0-100 and rank the fixes. + +Takes a JSON description of the profile YOU control (never scraped, never fetched — +you fill it in or dictate it) and returns a weighted completeness score plus a fix +list ordered by points-per-hour, so the first hour of work is the one that moves +the most. + +Weights are set by what the section actually does for discovery and conversion, +not by how prominent it looks. The headline and the About opening are worth more +than education because they are what a stranger reads before deciding to care. + +Input JSON (every key optional; missing = absent): + { + "photo": true, "banner_custom": false, "custom_url": true, + "headline": "...", "about": "...", + "current_role": {"title": "...", "bullets": ["cut X 40%", "..."]}, + "featured_items": 2, "featured_updated_days_ago": 400, + "skills": ["python", "sql"], "recommendations_received": 1, + "open_to_or_services": false, "education": true, "certifications": 0, + "contact_info": true, "days_since_last_post": 90 + } + +Exit codes: + 0 STRONG (>= 80) + 2 INCOMPLETE (50-79) — fix list returned + 3 WEAK (< 50) — the profile is costing you every visit it receives + +Stdlib only. No network. Deterministic. +""" + +import argparse +import json +import sys + +# (key, weight, effort_hours, label) +CHECKS = [ + ("photo", 8, 0.5, "Profile photo"), + ("banner", 5, 0.5, "Custom banner"), + ("headline", 14, 1.0, "Headline that is not just a job title"), + ("about", 14, 1.5, "About section with a self-contained opening"), + ("experience", 12, 2.0, "Current role written as outcomes, not duties"), + ("featured", 8, 0.5, "Featured section with something recent"), + ("skills", 6, 0.3, "Skills listed and relevant"), + ("recommendations", 8, 1.0, "Recommendations received"), + ("custom_url", 3, 0.1, "Custom profile URL"), + ("open_to", 4, 0.2, "Open To / Services block set"), + ("education", 3, 0.2, "Education filled in"), + ("certifications", 2, 0.5, "Certifications listed"), + ("contact_info", 3, 0.2, "A reachable contact method"), + ("activity", 10, 1.0, "Posted in the last 30 days"), +] + +WHY = { + "photo": "Profiles without a photo read as abandoned or fake; a stranger's first " + "judgement happens before they read a word.", + "banner": "The default blue banner is 1,584x396 pixels of unused positioning. It is the " + "cheapest place to state what you do.", + "headline": "It travels with every comment, search result, and invitation you send. A bare " + "job title spends that space saying nothing a reader could not guess.", + "about": "LinkedIn truncates it after roughly the first 265-300 characters. If those do not " + "stand alone, the rest is never read.", + "experience": "Duty lists are interchangeable across everyone with the same title. Outcomes " + "are the only part a reader cannot get elsewhere.", + "featured": "The one place you choose what a visitor sees first. Empty, and they see whatever " + "you last reposted.", + "skills": "Skills are a matching surface for search and for recruiter filters.", + "recommendations": "The only text on the profile written by someone other than you. Two " + "specific ones beat ten generic.", + "custom_url": "Shareable, memorable, and it is a two-minute fix.", + "open_to": "Tells LinkedIn's matching systems and human visitors what you want. Absent, they " + "guess — usually wrong.", + "education": "A weak signal on its own, but it is a common filter and a common icebreaker.", + "certifications": "Only worth points where the certification is a gate in your field.", + "contact_info": "A profile that converts interest into a conversation needs a way to start one.", + "activity": "A profile with no recent activity converts a visit into nothing. Consistency, not " + "volume, is the signal.", +} + +SAMPLE = { + "photo": True, + "banner_custom": False, + "custom_url": True, + "headline": "Senior Software Engineer at Acme", + "about": "Experienced engineer passionate about building great software.", + "current_role": {"title": "Senior Software Engineer", + "bullets": ["Responsible for backend services", + "Worked with cross-functional teams"]}, + "featured_items": 0, + "featured_updated_days_ago": None, + "skills": ["python", "sql", "aws"], + "recommendations_received": 0, + "open_to_or_services": False, + "education": True, + "certifications": 0, + "contact_info": True, + "days_since_last_post": 210, +} + +OUTCOME_WORDS = ("cut", "grew", "reduced", "increased", "shipped", "launched", "saved", + "doubled", "migrated", "led", "%", "x", "from", "to") + + +def evaluate_check(key: str, p: dict) -> tuple: + """Return (earned_fraction 0..1, detail string).""" + if key == "photo": + return (1.0, "present") if p.get("photo") else (0.0, "missing") + if key == "banner": + return (1.0, "custom") if p.get("banner_custom") else (0.0, "default LinkedIn banner") + if key == "headline": + h = (p.get("headline") or "").strip() + if not h: + return 0.0, "empty" + title_only = len(h) < 60 and "|" not in h and "for " not in h.lower() + return (0.4, f"job-title-shaped ({len(h)} chars) — run headline_scorer.py") if title_only \ + else (1.0, f"{len(h)} chars, structured") + if key == "about": + a = (p.get("about") or "").strip() + if not a: + return 0.0, "empty" + if len(a) < 300: + return 0.4, f"{len(a)} chars — too short to say anything specific" + if len(a) < 600: + return 0.7, f"{len(a)} chars — thin but usable" + return 1.0, f"{len(a)} chars" + if key == "experience": + role = p.get("current_role") or {} + bullets = role.get("bullets") or [] + if not role.get("title"): + return 0.0, "no current role listed" + if not bullets: + return 0.3, "role listed with no description" + with_outcome = [b for b in bullets + if any(w in b.lower() for w in OUTCOME_WORDS)] + frac = 0.4 + 0.6 * (len(with_outcome) / max(1, len(bullets))) + return min(1.0, frac), f"{len(with_outcome)}/{len(bullets)} bullets carry an outcome" + if key == "featured": + n = p.get("featured_items") or 0 + if not n: + return 0.0, "empty" + age = p.get("featured_updated_days_ago") + if age is not None and age > 365: + return 0.5, f"{n} item(s), last updated {age} days ago — stale" + return 1.0, f"{n} item(s)" + if key == "skills": + n = len(p.get("skills") or []) + return (1.0, f"{n} listed") if n >= 5 else (n / 5.0, f"only {n} listed") + if key == "recommendations": + n = p.get("recommendations_received") or 0 + return (1.0, f"{n} received") if n >= 2 else (n / 2.0, f"{n} received") + if key == "custom_url": + return (1.0, "set") if p.get("custom_url") else (0.0, "still the default /in/name-8a3f2b") + if key == "open_to": + return (1.0, "set") if p.get("open_to_or_services") else (0.0, "not set") + if key == "education": + return (1.0, "present") if p.get("education") else (0.0, "missing") + if key == "certifications": + n = p.get("certifications") or 0 + return (1.0, f"{n} listed") if n else (0.0, "none listed") + if key == "contact_info": + return (1.0, "present") if p.get("contact_info") else (0.0, "missing") + if key == "activity": + d = p.get("days_since_last_post") + if d is None: + return 0.0, "no posting activity reported" + if d <= 30: + return 1.0, f"last post {d} days ago" + if d <= 90: + return 0.4, f"last post {d} days ago — the profile reads as dormant" + return 0.0, f"last post {d} days ago — effectively inactive" + return 0.0, "unknown check" + + +def audit(profile: dict) -> dict: + rows, fixes, total = [], [], 0.0 + for key, weight, effort, label in CHECKS: + frac, detail = evaluate_check(key, profile) + earned = round(weight * frac, 2) + total += earned + rows.append({"check": key, "label": label, "weight": weight, + "earned": earned, "detail": detail}) + lost = round(weight - earned, 2) + if lost >= 0.5: + fixes.append({ + "check": key, "label": label, "points_available": lost, + "effort_hours": effort, + "points_per_hour": round(lost / effort, 1), + "current": detail, + "why": WHY[key], + }) + fixes.sort(key=lambda f: (-f["points_per_hour"], -f["points_available"])) + score = round(total) + verdict, code = (("STRONG", 0) if score >= 80 else + ("INCOMPLETE", 2) if score >= 50 else ("WEAK", 3)) + first_hour = [] + budget = 1.0 + for f in fixes: + if f["effort_hours"] <= budget: + first_hour.append(f["label"]) + budget -= f["effort_hours"] + return { + "score": score, + "verdict": verdict, + "exit_code": code, + "checks": rows, + "fixes_by_leverage": fixes, + "first_hour_plan": first_hour, + "points_recoverable_in_first_hour": round( + sum(f["points_available"] for f in fixes if f["label"] in first_hour), 1), + "note": ("Scores your own profile from your own description. Nothing is fetched, " + "scraped, or sent. Weights reflect discovery and conversion impact, not " + "LinkedIn's internal 'profile strength' meter, which is a different and " + "undocumented measure."), + } + + +def render_human(r: dict) -> str: + lines = [f"Profile completeness: {r['score']}/100 — {r['verdict']}", "=" * 56] + for row in r["checks"]: + mark = "OK " if row["earned"] >= row["weight"] - 0.01 else "-- " + lines.append(f" {mark}{row['label']:<48} {row['earned']:>5}/{row['weight']:<3} {row['detail']}") + lines.append("\nFixes ranked by points per hour:") + for f in r["fixes_by_leverage"]: + lines.append(f" +{f['points_available']:<5} pts ~{f['effort_hours']}h " + f"({f['points_per_hour']} pts/h) {f['label']}") + lines.append(f" now : {f['current']}") + lines.append(f" why : {f['why']}") + if r["first_hour_plan"]: + lines.append(f"\nFirst hour: {', '.join(r['first_hour_plan'])} " + f"(+{r['points_recoverable_in_first_hour']} points)") + lines.append(f"\n{r['note']}") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Audit a LinkedIn profile 0-100 and rank fixes by points per hour " + "(STRONG=0 / INCOMPLETE=2 / WEAK=3).") + src = ap.add_mutually_exclusive_group() + src.add_argument("--input", help="Profile JSON file ('-' for stdin).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", + help="Audit a built-in sample profile (a typical unoptimized one).") + ap.add_argument("--print-schema", action="store_true", + help="Print the input JSON schema/sample and exit.") + args = ap.parse_args() + + if args.print_schema: + print(json.dumps(SAMPLE, indent=2)) + return 0 + if args.sample: + profile = SAMPLE + elif args.input: + raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + try: + profile = json.loads(raw) + except json.JSONDecodeError as exc: + print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr) + return 4 + else: + ap.error("--input or --sample is required (see --print-schema)") + + result = audit(profile) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-skills/SKILL.md b/marketing/linkedin/skills/linkedin-skills/SKILL.md new file mode 100644 index 000000000..f43da72a4 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-skills/SKILL.md @@ -0,0 +1,100 @@ +--- +name: linkedin-skills +description: Use when someone wants to grow an organic LinkedIn presence — a content strategy for a career change or consulting or thought leadership, a rewritten profile or headline, post drafts and hooks, a posting cadence or newsletter plan, connection notes and outreach, a commenting strategy, repurposing an article or talk into posts, or a read on why their reach dropped. Triggers on "grow my LinkedIn", "fix my headline", "write a LinkedIn post", "what should I post about", "LinkedIn strategy", "connection request", "my reach dropped". Forks context to route to one of five sub-skills, and refuses automation, scraping, pods, and bulk DMs before any drafting starts. +context: fork +license: MIT +metadata: + version: 1.0.0 + author: Alireza Rezvani + category: marketing + updated: 2026-08-25 + build_pattern: "Domain orchestrator — deterministic lane router + policy gate, five managed sub-skills" + distinct_from: "marketing-skill/x-twitter-growth (X/Twitter-specific); marketing-skill/social-content (multi-platform calendars); marketing-skill/social-media-analyzer (cross-platform reporting); productivity/andreessen (venture judgement, not audience building)" +--- + +# LinkedIn — Organic Presence Orchestrator + +> **Portability + safety:** 17 stdlib-only Python scripts. **No LinkedIn credentials, no API +> calls, no scraping, nothing auto-sent.** Every output is text the account holder posts +> themselves — automated posting, connecting, and commenting are prohibited by LinkedIn's +> User Agreement §8.2, and a restricted account ends the whole project. + +Building an authentic presence over months, not generating a post. The five lanes cover what +actually moves: who you are (profile), what you stand for (strategy), what you publish +(content), who you talk to (engagement), and what any of it did (analytics). + +## Step 1 — Gate, before anything is drafted + +```bash +python3 scripts/linkedin_policy_gate.py --text "" --output human +``` + +- **exit 4 REFUSE** — a named rule is broken (automation, scraping, pods, bulk DMs, fake + identity, fabricated proof, a prohibited automation platform). Do not draft it: name the + rule, offer the substitute the gate prints, and route there instead. A REFUSE outranks any + route below. +- **exit 3 CONSTRAIN** — proceed, and state the constraint out loud in your reply. +- **exit 0 ALLOW** — proceed. + +## Step 2 — Route + +```bash +python3 scripts/linkedin_goal_router.py --text "" --output human +``` + +Exit 0 routes — invoke that lane, surfacing any `prerequisite` as a question first. +Exit 2 is ambiguous — ask **one** question naming both candidates, with a recommendation. +Exit 3 has no signal — ask what they want to walk away with, rather than guessing. + +| Lane | Typical ask | +|---|---| +| `linkedin-profile` | "fix my headline", "my profile gets views but nothing happens" | +| `linkedin-strategy` | "what should I post about", "how often", "should I start a newsletter" | +| `linkedin-content` | "write this post", "turn my talk into posts", "is this hook any good" | +| `linkedin-engagement` | "who should I comment on", "write a connection note" | +| `linkedin-analytics` | "why did reach drop", "what's working", "should I test this" | + +## Step 3 — Forcing questions + +One at a time, each with a recommended answer. Stop as soon as the lane can run honestly. +`/cs:grill-linkedin` walks all five with the recommendation and the canon behind each. + +1. What has to be true in 90 days for this to have been worth it? *(Not a follower count.)* +2. Who is this for, specifically enough that someone is excluded? +3. How many minutes a week will you protect — measured from a bad week? +4. What proof already exists? *(If none, the first pillar is process, not results.)* +5. What will you not post about? + +## Hard rules + +- **Nothing is automated and nothing is sent.** No credentials, no API, no scraping. +- **Refuse, then substitute.** Every REFUSE names the compliant alternative. +- **The account holder is the author of record.** They read every line before it ships. +- **No claim they cannot substantiate.** A real number, a bounded range, or nothing. +- **Never silently chain lanes.** Offer the next one as a question. +- **Cite the confidence level.** LinkedIn-official is 🟢, third-party studies are 🟡, + folklore is 🔴 and gets named as folklore. + +## Scripts + +| Script | Role | +|---|---| +| [`scripts/linkedin_policy_gate.py`](scripts/linkedin_policy_gate.py) | ALLOW / CONSTRAIN / REFUSE against User Agreement §8.2 + Community Policies. 7 refusal rules, each with a substitute. | +| [`scripts/linkedin_goal_router.py`](scripts/linkedin_goal_router.py) | Deterministic five-lane classifier: route (0) / ask (2) / no-signal (3), with cross-lane prerequisites. | + +## References and assets + +- [`references/linkedin_platform_canon.md`](references/linkedin_platform_canon.md) — how the feed ranks, evidence-graded (7 sources) +- [`references/policy_and_account_safety.md`](references/policy_and_account_safety.md) — §8.2, prohibited software, restriction triggers (7 sources) +- [`assets/linkedin_operating_agreement.md`](assets/linkedin_operating_agreement.md) — the standing rules in one page, agreed before work starts + +## Distinct from + +- **`marketing-skill/x-twitter-growth`** — X/Twitter mechanics. Different platform and rules. +- **`marketing-skill/social-content` / `social-media-manager`** — multi-platform brand + calendars. This is one person's presence on one platform, in depth. +- **`marketing-skill/social-media-analyzer`** — cross-platform campaign reporting. + +--- + +**Version:** 1.0.0 · 5 lanes · 17 stdlib scripts · nothing auto-sent diff --git a/marketing/linkedin/skills/linkedin-skills/assets/linkedin_operating_agreement.md b/marketing/linkedin/skills/linkedin-skills/assets/linkedin_operating_agreement.md new file mode 100644 index 000000000..21f28a2c8 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-skills/assets/linkedin_operating_agreement.md @@ -0,0 +1,80 @@ +# LinkedIn Operating Agreement + +One page. Read it before the first post; it decides what this work can and cannot be. + +--- + +## What this plugin will do + +- Audit and rewrite your profile, headline, and About section. +- Build a positioning brief, content pillars, a cadence you can hold, and a newsletter plan. +- Draft, lint, and repurpose posts in any native format. +- Build a commenting roster and write outreach messages one person at a time. +- Analyse your own exported post data and refuse to over-conclude from it. + +## What it will never do + +- **Log into LinkedIn.** It holds no credentials and makes no API calls. +- **Send anything.** No posts, invitations, messages, comments, or likes. Every output is + text you paste yourself. +- **Scrape.** No profile data, no post data, no email finding, no list building. +- **Automate activity.** No scheduling tools that act as you, no browser extensions, no + named third-party automation platforms. +- **Join or simulate an engagement pod.** +- **Invent a number, a client, a result, a credential, or a quote.** + +Those are not preferences. Automated activity, scraping, and inauthentic engagement are +prohibited by LinkedIn's User Agreement §8.2, and a restricted account ends a compounding +asset that took months to build. + +--- + +## What you are agreeing to + +**1. You are the author of record.** +Your name is on it. You read every line before it publishes, and you cut anything you would +not say out loud in a room of peers. Drafting help is normal; unread output is how people +end up publishing something untrue about their own work. + +**2. Every claim is one you can substantiate.** +A real number, a bounded range, or a qualitative statement. If the proof does not exist yet, +the post is about the process — which is a legitimate post and it ages better than the +alternative. + +**3. You check what you are allowed to say.** +Employment agreement, client NDA, and any sector rules (financial promotion, medical claims, +securities disclosure) sit outside LinkedIn's policies and bind you anyway. Named third +parties should consent. + +**4. You will hold a cadence you can actually pay for.** +Priced from a bad week, not a good one. A cadence abandoned in week five is worse than one +never started, because the abandonment is visible on your profile. + +**5. You measure the outcome, not the applause.** +The 90-day criteria are things another person could verify: conversations you did not start, +specific references, invitations. Follower count is not on the list. + +--- + +## What you should expect + +| | | +|---|---| +| **First visible change** | Profile fixes, same day | +| **First reliable signal** | 6-8 weeks of consistent posting and commenting | +| **Enough data to analyse** | 10+ posts, and even then descriptively | +| **Enough data to test a hypothesis** | 20-60 posts, often more than a quarter allows | +| **A pattern you can act on** | Usually later than you want, and the honest answer in the meantime is "not yet" | + +This is slower than the shortcuts promise. The shortcuts put the account at risk to save a +few months, and that is bad arithmetic. + +--- + +## Signature + +- Objective (one of six): ______________________ +- Audience (specific enough to exclude someone): ______________________ +- Minutes per week I will protect: ______ +- Two things I will not post about: ______________________ +- Date: ______________________ diff --git a/marketing/linkedin/skills/linkedin-skills/references/linkedin_platform_canon.md b/marketing/linkedin/skills/linkedin-skills/references/linkedin_platform_canon.md new file mode 100644 index 000000000..8c6802ba8 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-skills/references/linkedin_platform_canon.md @@ -0,0 +1,154 @@ +# LinkedIn Platform Canon — what is known, what is inferred, what is folklore + +Every quantitative claim below carries a confidence level. This matters more on +LinkedIn than on most platforms: the advice ecosystem is dominated by people +selling LinkedIn advice, almost none of the numbers come from LinkedIn, and the +platform changes the surfaces those numbers describe without announcing it. + +**Confidence key** +- 🟢 **High** — stated by LinkedIn in official documentation, engineering + publications, or peer-reviewed papers authored by LinkedIn engineers. +- 🟡 **Medium** — large-N third-party studies of public posts. Directionally + useful, methodologically opaque, and the sample is always public posts from + accounts that opted into being measured. +- 🔴 **Low / folklore** — repeated widely, sourced nowhere. Listed so you can + recognise it, not so you can use it. + +--- + +## 1. How the feed actually ranks — 🟢 + +LinkedIn's feed is a multi-stage retrieval-and-ranking system, not a chronological +timeline and not a single "algorithm". The public record is unusually good here +because LinkedIn's engineering organisation publishes: + +- **LiRank** (Borisyuk et al., KDD 2024, arXiv:2402.06859) describes the + production ranking stack for Feed, Ads CTR, and Job recommendations — + Residual DCN architecture, multi-task objectives, and isotonic calibration. + The relevant fact for a creator is structural: **the model optimises several + objectives at once**, and no single engagement action is "the" ranking signal. +- **Dwell time** is an explicit feed-ranking objective. LinkedIn's engineering + blog documents two measures — dwell "on the feed" (starting when at least half + of an update is visible during a scroll) and dwell "after the click" — and + operationalises them as a *Long Dwell* classifier predicting whether a member's + dwell will exceed a context-dependent threshold. + +**What follows for a creator, and what does not.** + +Follows: a post that holds attention is being measured on that, so a post worth +reading all the way through is not merely a nice-to-have. Text that rewards the +expand click is doing something the ranker can see. + +Does *not* follow: that you should pad posts to increase dwell. Dwell is measured +against a *context-dependent percentile*, not an absolute seconds count, and +padding also depresses the completion and interaction signals that sit alongside +it in a multi-task model. "Add fluff to raise dwell time" is 🔴 folklore built on +a 🟢 fact. + +## 2. Comments are the expensive signal — 🟡 (mechanism 🟢) + +A reaction costs a reader one tap. A substantive comment costs them thirty +seconds and a small reputational exposure in front of their own network. Third- +party studies consistently find comment-heavy posts travel further, and the +mechanism is consistent with a multi-objective ranker that also predicts +downstream sessions: a comment creates a notification, a return visit, and a +thread other people can enter. + +The practical version: **write posts that give a competent reader something to +add.** A post that is complete, correct, and closed invites agreement, and +agreement is the cheapest and least valuable response you can earn. + +## 3. Reach is falling and the base has shifted — 🟡 + +Richard van der Blom's annual *Algorithm Insights* report (Just Connecting) is +the most-cited third-party longitudinal study, drawing on over a million public +posts. Recent editions report substantial year-over-year declines in organic +views, engagement, and follower growth, and an average post reaching roughly +8-12% of a creator's followers. + +Treat the *direction* as reliable and the *specific percentages* as indicative. +The sample is public posts, the methodology is not independently auditable, and +"reach" is measured through the same impressions counter whose definition +LinkedIn has revised more than once. + +What the trend means practically: **follower count is a worse proxy for +distribution every year.** A focused audience of a few thousand people who +actually work in your field will out-perform a large unfocused one, because +relevance-based retrieval has to decide *who* to show a post to, and an unfocused +follower graph gives it nothing to work with. + +## 4. External links in the post body — 🟡, contested + +Third-party analysis (van der Blom, 2026 edition, ~1.3M posts) reports a body +link reducing median reach by roughly 19%, and much larger suppression for links +placed in comments in some measurements. **LinkedIn has never confirmed a link +penalty**, and a plausible non-punitive explanation exists: a link that takes a +reader off-platform truncates dwell, and dwell is a ranking objective. + +The practical guidance is the same under either explanation, which is why it is +safe to follow: put the link in the first comment, say "link in the comments" in +the post, and keep it in the body only when the click *is* the goal and you +accept the reach cost. + +## 5. The first 60-90 minutes — 🟡 + +Early engagement correlates strongly with eventual reach in every third-party +dataset. The causal story is unproven and probably bidirectional (good posts get +early engagement *and* early engagement helps distribution). Either way the +behavioural implication holds: **be available to reply for an hour after you +post.** Replying is also the cheapest way to add comments to your own post +honestly. + +🔴 The "golden hour" as a precise, engineered window with a hard cutoff is +folklore. Nobody outside LinkedIn knows the decay function. + +## 6. Hashtags — 🟡, declining relevance + +LinkedIn has deprecated hashtag-following surfaces over time and the platform has +moved toward semantic retrieval. Two or three topical hashtags remain a cheap, +harmless topic signal. Ten hashtags signal reach-chasing to human readers, which +is the cost that actually matters now. + +## 7. What LinkedIn does *not* publish — worth knowing + +- The exact weekly invitation limit (widely observed around 100, adjusted per + account). +- The exact character position where "…see more" truncates, per surface. +- The full newsletter eligibility criteria (the >150 followers/connections + threshold is published; "a set of criteria, all of which must be met" is not). +- Any per-signal ranking weight. + +Anyone quoting a precise figure for these is quoting an observation, not a +specification. Cite it that way. + +--- + +## Sources + +1. Borisyuk, F. et al. **"LiRank: Industrial Large Scale Ranking Models at + LinkedIn."** KDD 2024. arXiv:2402.06859. — production ranking architecture, + multi-task objectives. +2. LinkedIn Engineering Blog. **"Understanding feed dwell time to improve + LinkedIn feed ranking."** linkedin.com/blog/engineering/feed/understanding-feed-dwell-time + — the two dwell measures and the Long Dwell classifier. +3. LinkedIn. **User Agreement**, §8.2 "Don'ts." + linkedin.com/legal/user-agreement — the binding rules on automation, scraping, + and inauthentic engagement. +4. LinkedIn. **Professional Community Policies.** + linkedin.com/legal/professional-community-policies — authenticity, spam, and + engagement-bait rules. +5. van der Blom, R. **Algorithm Insights** (annual, Just Connecting) — the + longest-running third-party longitudinal study of LinkedIn organic + performance. 🟡 sample is public posts; methodology not independently audited. +6. LinkedIn Help. **"LinkedIn Newsletter access criteria."** — the >150 + followers/connections threshold and the existence of unpublished criteria. +7. Nielsen Norman Group. **"How People Read Online"** and the F-shaped reading + pattern research — why the visible first lines carry the decision, on any + platform with a truncated preview. + +--- + +**Rule of thumb for anything not on this page:** if a claim about LinkedIn comes +with a precise percentage and no named study, it is 🔴. Say so out loud rather +than repeating it, and give the user the version of the advice that holds +regardless of whether the number is true. diff --git a/marketing/linkedin/skills/linkedin-skills/references/policy_and_account_safety.md b/marketing/linkedin/skills/linkedin-skills/references/policy_and_account_safety.md new file mode 100644 index 000000000..b7928434c --- /dev/null +++ b/marketing/linkedin/skills/linkedin-skills/references/policy_and_account_safety.md @@ -0,0 +1,153 @@ +# Policy and Account Safety — the rules that end the project if you break them + +Organic LinkedIn growth is a compounding asset with a single point of failure: +the account. A restriction resets it to zero and there is no appeals process you +can schedule around. This is why this plugin gates every lane through +`linkedin_policy_gate.py` before drafting anything, and why it has no LinkedIn +credentials at all. + +None of this is legal advice. It is a summary of published rules, written so an +agent can refuse the right things for the right reasons. + +--- + +## 1. What the User Agreement actually prohibits (§8.2 "Don'ts") + +LinkedIn's User Agreement prohibits members from, among other things: + +- **Developing, supporting, or using software, devices, scripts, robots, or any + other means or processes** — including crawlers, browser plugins and add-ons — + **to scrape the Services or otherwise copy profiles and other data.** +- **Using bots or other automated methods** to access the Services, add or + download contacts, send or redirect messages, or **create, comment on, like, + share, or re-share posts, or otherwise drive inauthentic engagement.** +- Creating a false identity, misrepresenting your identity, or using someone + else's account. +- Posting inaccurate information, or content that violates the Professional + Community Policies. + +Read the second bullet carefully: **it names commenting, liking, and sharing.** +The prohibition is not limited to sending messages. An engagement pod that +coordinates real humans to reciprocally comment on schedule is squarely inside +"otherwise drive inauthentic engagement", and the fact that a human pressed the +key does not take it outside the rule. + +## 2. Third-party tools + +LinkedIn maintains a Help article on **Prohibited Software and Extensions** +stating that it does not permit third-party software — crawlers, bots, browser +plug-ins, or extensions — that scrapes, modifies the appearance of, or automates +activity on LinkedIn. It notes two consequences: accounts may be restricted or +closed, and the tools themselves may stop working without notice. + +The named-tool list in `linkedin_policy_gate.py` (Dux-Soup, PhantomBuster, +Expandi, Linked Helper, Meet Alfred, Waalaxy, Octopus CRM, Lempod, and others) is +not an official LinkedIn blacklist — it is a list of tools whose advertised +function is exactly what the policy prohibits. The gate refuses on the function, +and names the tool only because that is how people describe what they want. + +**The supported path exists:** LinkedIn's own scheduler for posts, and LinkedIn's +Marketing Developer Platform for partners with API access. If a workflow can be +done through those, it is fine. + +## 3. What actually triggers a restriction + +LinkedIn does not publish its enforcement thresholds. Observable patterns from +LinkedIn's own Help documentation on account restrictions and from widespread +reporting: + +| Trigger | Why it fires | +|---|---| +| High invitation volume with low acceptance | The signature of untargeted bulk invites. Acceptance rate is the discriminator, not volume alone. | +| Many "I don't know this person" / spam reports | Recipient-side signal, and the most damaging one. | +| Machine-regular activity patterns | Constant per-hour rates, activity at 03:00 local, identical intervals. | +| Identical message or comment text at volume | Directly matches the "inauthentic engagement" language. | +| Detected automation extension | Automated detection of the prohibited-software class. | +| Profile data inconsistent with a real person | Stock photo, no history, sudden high-volume activity. | + +The practical read: **restrictions correlate with looking automated more than +with volume itself.** A person sending 20 genuinely personal invitations a week +for a year is invisible. A person sending 200 identical ones in a day is not. + +## 4. Rate limits worth knowing (🟡 observed, not published) + +- **Connection invitations:** a weekly limit widely observed around 100, adjusted + per account. **Pending invitations count against it**, so a backlog of + un-actioned invites silently shrinks the allowance. +- **Withdrawn invitations** cannot be re-sent to the same person for roughly + three weeks. +- **Messaging:** no published cap for first-degree connections; InMail credits + are metered by subscription. + +`outreach_volume_guard.py` uses conservative working numbers and says explicitly +that they are observations. + +## 5. Content rules that cost reach rather than the account + +The Professional Community Policies commit LinkedIn to reducing distribution of +several content classes. Relevant to organic creators: + +- **Engagement bait** — "comment X for the guide", "like if you agree", "tag + three people". Explicitly named. +- **Spam and unsolicited commercial content**, including repetitive posting. +- **Misleading or false content**, which for a professional audience includes + fabricated metrics and invented case studies. + +These do not usually restrict an account. They make the work not work, which for +a compounding organic strategy is nearly as bad. + +## 6. Things this plugin refuses even where LinkedIn permits them + +Three refusals are editorial rather than legal, and they are held anyway: + +1. **Fabricated proof.** Inventing a metric, a client, or a testimonial. Legal + exposure aside (FTC endorsement rules apply to testimonials in many + jurisdictions), a professional audience contains people who can check. +2. **Ghostwriting an executive's account without their knowledge.** Ghostwriting + with the account holder's review is normal and fine; the account holder is the + author of record either way, which means they have to have read it. +3. **Posting confidential or identifying detail about employers, clients, or + individuals without consent.** Employment agreements, NDAs, and sector rules + (financial promotion, medical claims, securities disclosure) sit outside + LinkedIn's policies entirely and bind you anyway. + +## 7. Regional obligations the plugin surfaces but cannot resolve + +- **GDPR / UK GDPR** — sending unsolicited B2B messages, and any processing of + contact data outside LinkedIn, has a legal basis question attached. Scraping + member data to build a list has a much harder one. +- **EU Digital Services Act** and comparable regimes — platform transparency and + reporting obligations that affect what recourse you have, not what you may do. +- **FTC Endorsement Guides** (US) and equivalents — disclosure obligations for + paid or incentivised endorsements, including on personal profiles. + +Where any of these are in play, the answer is a named human — legal, compliance, +or the client — not a tool. + +--- + +## Sources + +1. LinkedIn. **User Agreement**, §8.2 "Don'ts." + linkedin.com/legal/user-agreement +2. LinkedIn Help. **"Prohibited software and extensions."** + linkedin.com/help/linkedin/answer/a1341387 +3. LinkedIn Help. **"Automated activity on LinkedIn."** + linkedin.com/help/linkedin/answer/a1340567 +4. LinkedIn Help. **"Account restrictions."** + linkedin.com/help/linkedin/answer/a1340522 +5. LinkedIn. **Professional Community Policies.** + linkedin.com/legal/professional-community-policies — authenticity, spam, + engagement bait, misinformation. +6. LinkedIn. **Marketing Developer Platform** documentation — the supported + programmatic path, and the scope it is actually granted for. +7. US Federal Trade Commission. **Guides Concerning the Use of Endorsements and + Testimonials in Advertising** (16 CFR Part 255) — why an invented testimonial + is a legal problem and not only an editorial one. + +--- + +**The one-line version:** you can build a serious LinkedIn presence entirely +inside the rules, it takes longer than the shortcuts promise, and the shortcuts +put the compounding asset at risk to save a few months. That trade is bad +arithmetic, and it is the reason this plugin refuses rather than warns. diff --git a/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_goal_router.py b/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_goal_router.py new file mode 100644 index 000000000..ecaaac6ac --- /dev/null +++ b/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_goal_router.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""linkedin_goal_router.py — deterministic lane classifier for the linkedin domain. + +Scores a LinkedIn goal against the five sub-skill lanes using the same two-signal +threshold discipline as the commercial / research-ops / product-team orchestrators. +Emits a routing decision an agent can branch on mechanically instead of guessing. + +Exit codes: + 0 confident route emitted (route_to set) + 2 ambiguous — ask ONE clarifying question naming the top two lanes + 3 no signal — do not guess; ask the user to restate the goal with a deliverable + +Stdlib only. Deterministic: same text in, same route out. +""" + +import argparse +import json +import sys + +SIGNALS = { + "PROFILE": { + "skill": "linkedin-profile", + "path": "marketing/linkedin/skills/linkedin-profile", + "keywords": [ + "profile", "headline", "about section", "about me", "summary section", + "banner", "featured section", "experience section", "bio", "tagline", + "profile photo", "skills section", "recommendations", "open to work", + "creator mode", "custom url", "profile audit", "optimize my profile", + ], + }, + "STRATEGY": { + "skill": "linkedin-strategy", + "path": "marketing/linkedin/skills/linkedin-strategy", + "keywords": [ + "strategy", "content pillars", "pillar", "positioning", "cadence", + "posting schedule", "how often should i post", "calendar", "editorial calendar", + "newsletter", "thought leadership", "career change", "career transition", + "personal brand", "audience", "niche", "90 day", "quarter", "roadmap", + "goals", "objective", "grow my following", "grow an audience", + ], + }, + "CONTENT": { + "skill": "linkedin-content", + "path": "marketing/linkedin/skills/linkedin-content", + "keywords": [ + "write a post", "draft a post", "post idea", "post ideas", "hook", + "carousel", "document post", "pdf post", "story post", "how-to post", + "opinion post", "listicle", "caption", "video script", "poll", + "article", "repurpose", "turn this into", "rewrite this post", + "edit my post", "review my post", "first line", "content", "copy", + ], + }, + "ENGAGEMENT": { + "skill": "linkedin-engagement", + "path": "marketing/linkedin/skills/linkedin-engagement", + "keywords": [ + "comment", "commenting", "reply", "replies", "dm", "message", + "connection request", "connection note", "invite", "inmail", "outreach", + "networking", "cold message", "follow up", "who should i engage", + "engagement strategy", "groups", "community", "reach out", "warm intro", + ], + }, + "ANALYTICS": { + "skill": "linkedin-analytics", + "path": "marketing/linkedin/skills/linkedin-analytics", + "keywords": [ + "analytics", "analyze my posts", "which posts", "performance", + "impressions", "engagement rate", "reach dropped", "what's working", + "what is working", "export", "benchmark", "pattern", "why did this post", + "top posts", "experiment", "test", "measure", "metrics", "dashboard", + "followers gained", "profile views", + ], + }, +} + +# Cross-lane dependencies used when the router says ASK or when a lane is chosen: +# these are stated as prerequisites, not silently chained. +PREREQS = { + "CONTENT": ("STRATEGY", "Posts without pillars are noise. If no positioning brief exists, " + "offer linkedin-strategy first — but never chain silently."), + "ANALYTICS": ("CONTENT", "Pattern mining needs a body of posts. Under ~20 posts, the honest " + "answer is 'not enough data yet' — say so rather than fitting noise."), + "ENGAGEMENT": ("PROFILE", "Comments and DMs drive profile visits. A weak headline wastes " + "every visit engagement earns."), +} + +SAMPLE_GOAL = ("I'm moving from backend engineering into developer advocacy and want to build " + "a real audience over the next two quarters — what should I be posting about, " + "and how often?") + + +def score(text: str) -> dict: + low = text.lower() + scores, hits = {}, {} + for lane, spec in SIGNALS.items(): + matched = [kw for kw in spec["keywords"] if kw in low] + scores[lane] = len(matched) + hits[lane] = matched + return {"scores": scores, "hits": hits} + + +def decide(scores: dict) -> dict: + ranked = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0])) + (top_lane, top), (second_lane, second) = ranked[0], ranked[1] + if top == 0: + return {"decision": "NO_SIGNAL", "exit": 3} + if top >= 2 and (second == 0 or top >= 2 * second): + return {"decision": "ROUTE", "lane": top_lane, "exit": 0} + candidates = [top_lane] + ([second_lane] if second > 0 else []) + return {"decision": "ASK", "candidates": candidates, "exit": 2} + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Deterministic lane router for LinkedIn goals " + "(route=0 / ask=2 / no-signal=3).") + src = ap.add_mutually_exclusive_group() + src.add_argument("--text", help="Goal or inquiry text to classify.") + src.add_argument("--input", help="Read goal text from a file ('-' for stdin).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", + help="Classify a built-in sample goal and exit.") + args = ap.parse_args() + + if args.sample: + text = SAMPLE_GOAL + elif args.text: + text = args.text + elif args.input: + text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + else: + ap.error("one of --text, --input, or --sample is required") + + result = score(text) + verdict = decide(result["scores"]) + out = { + "goal": text.strip()[:300], + "scores": {k: v for k, v in result["scores"].items() if v}, + "decision": verdict["decision"], + "policy_gate": ("Run linkedin_policy_gate.py on the same text before drafting anything. " + "A REFUSE there outranks any route here."), + } + + if verdict["decision"] == "ROUTE": + lane = verdict["lane"] + out["route_to"] = SIGNALS[lane]["skill"] + out["skill_path"] = SIGNALS[lane]["path"] + out["matched_signals"] = result["hits"][lane] + if lane in PREREQS: + prereq_lane, why = PREREQS[lane] + out["prerequisite"] = { + "lane": SIGNALS[prereq_lane]["skill"], + "why": why, + "rule": "Offer it as a question. Never chain silently.", + } + elif verdict["decision"] == "ASK": + out["candidates"] = [ + {"lane": lane, "skill": SIGNALS[lane]["skill"], "score": result["scores"][lane]} + for lane in verdict["candidates"] + ] + out["instruction"] = ("Ask ONE clarifying question naming both candidate lanes, with a " + "recommended answer and the reason. Never guess silently.") + else: + out["instruction"] = ("No lane signal. Ask the user what they want to walk away with — " + "a rewritten profile, a posting plan, a drafted post, an outreach " + "message, or a read on their numbers. Do not route on fuzz.") + + if args.output == "json": + print(json.dumps(out, indent=2)) + else: + print(f"Decision: {out['decision']}") + if "route_to" in out: + print(f"Route to: {out['route_to']} ({out['skill_path']})") + print(f"Signals : {', '.join(out['matched_signals'])}") + if "prerequisite" in out: + print(f"Prereq : {out['prerequisite']['lane']} — {out['prerequisite']['why']}") + elif "candidates" in out: + print("Ambiguous: " + " vs ".join(c["skill"] for c in out["candidates"])) + print(out["instruction"]) + else: + print(out["instruction"]) + print(f"\nPolicy : {out['policy_gate']}") + return verdict["exit"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_policy_gate.py b/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_policy_gate.py new file mode 100644 index 000000000..87256a850 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-skills/scripts/linkedin_policy_gate.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""linkedin_policy_gate.py — refuse LinkedIn tactics that break the User Agreement. + +Every lane in the `linkedin` plugin passes through this gate before any drafting +work happens. It classifies a described tactic against LinkedIn's User Agreement +(§8.2 "Don'ts"), the Prohibited Software and Extensions policy, and the +Professional Community Policies, then returns ALLOW / CONSTRAIN / REFUSE. + +The point is not legal advice. The point is that an account restriction ends the +organic-growth project, so the tactics that risk one are refused up front instead +of drafted and regretted. A REFUSE always names the rule and offers the compliant +substitute — this gate never just says no. + +Deterministic: same text in, same verdict out. Stdlib only. No network calls, +no LinkedIn API access, nothing is ever sent anywhere. + +Exit codes: + 0 ALLOW — nothing in the request trips a rule; proceed + 3 CONSTRAIN — allowed, but only under the named constraints (proceed and honor them) + 4 REFUSE — breaks a named rule; do not draft it, offer the substitute instead + +Usage: + python3 linkedin_policy_gate.py --text "auto-connect with 500 recruiters a week" + python3 linkedin_policy_gate.py --input plan.md --output human + python3 linkedin_policy_gate.py --sample +""" + +import argparse +import json +import re +import sys + +# --------------------------------------------------------------------------- +# Refusal rules. Each carries the policy anchor and the compliant substitute, +# because a gate that only blocks teaches the user nothing. +# --------------------------------------------------------------------------- +REFUSE_RULES = [ + { + "id": "P1-AUTOMATION", + "title": "Automated activity on LinkedIn", + "anchor": "LinkedIn User Agreement §8.2 (bots/automated methods to access the " + "Services, add contacts, send messages, create/comment/like/share posts)", + "patterns": [ + r"\bauto[- ]?(connect|invite|dm|message|like|comment|follow|endorse|post|apply)\w*", + r"\bautomat(e|ed|ing|ion)\b(?!.{0,40}\bis (prohibited|banned|against)\b)", + r"\bbot\b", r"\bbots\b", r"\bheadless browser\b", r"\bselenium\b", r"\bpuppeteer\b", + r"\bbrowser (extension|plugin|add-?on)\b.{0,40}\b(linkedin|connect|message)\b", + r"\bscript that (logs? in|clicks?|sends?|connects?)\b", + ], + "substitute": "Do the same volume by hand on a capped schedule. " + "`linkedin-engagement/scripts/outreach_volume_guard.py` sizes a manual " + "cadence you can actually sustain; the plugin drafts the text, you press send.", + }, + { + "id": "P2-SCRAPING", + "title": "Scraping or bulk-copying member data", + "anchor": "LinkedIn User Agreement §8.2 (crawlers/scrapers to copy profiles or other " + "data) + Prohibited Software and Extensions policy", + "patterns": [ + r"\bscrap(e|ed|er|ing)\b", r"\bcrawl(er|ing)?\b", + r"\b(harvest|extract|mine)\b.{0,30}\b(email|profile|contact|lead|member)s?\b", + r"\bexport\b.{0,25}\b(connections?|leads?|profiles?|members?)\b.{0,25}\b(list|database|csv)\b", + r"\bemail finder\b", r"\bfind (their|his|her) email\b", + r"\bbuild(ing)? a (lead )?(list|database)\b.{0,30}\bfrom linkedin\b", + ], + "substitute": "Use LinkedIn's own export of YOUR data (Settings → Data privacy → " + "Get a copy of your data) and LinkedIn-native search. Analytics work in " + "this plugin runs on your own exported post/profile stats, never on " + "other members' data.", + }, + { + "id": "P3-INAUTHENTIC", + "title": "Inauthentic engagement (pods, bought signals)", + "anchor": "LinkedIn User Agreement §8.2 (drive inauthentic engagement) + Professional " + "Community Policies (be authentic / no fake engagement)", + "patterns": [ + r"\b(engagement|comment|like|linkedin) ?pod\b", r"\bpods?\b(?=.{0,30}\b(join|run|group)\b)", + r"\bbuy(ing)? (followers?|likes?|comments?|connections?|views?|impressions?)\b", + r"\b(fake|paid|bought|purchased) (followers?|engagement|likes?|comments?)\b", + r"\bengagement (group|ring|circle|exchange|swap)\b", + r"\blike[- ]?for[- ]?like\b", r"\bcomment[- ]?for[- ]?comment\b", + ], + "substitute": "Build a real reciprocity list instead: " + "`linkedin-engagement/scripts/comment_target_planner.py` picks accounts " + "whose audience overlaps yours and budgets genuine daily comments. Slower, " + "and it survives an audit.", + }, + { + "id": "P4-IDENTITY", + "title": "Fake identity, duplicate accounts, impersonation", + "anchor": "LinkedIn User Agreement §8.2 (create a false identity, misrepresent your " + "identity, use another's account) + Professional Community Policies", + "patterns": [ + r"\bfake (profile|account|persona|identity)\b", + r"\b(second|burner|dummy|alt|multiple) (linkedin )?accounts?\b", + r"\bimpersonat(e|ing|ion)\b", + r"\bpretend(ing)? to be\b", r"\bpose as\b", + r"\bghost(write|writing)\b.{0,30}\bwithout\b.{0,20}\b(disclos|know)\w*", + r"\bpost as (?:my|the) (ceo|founder|boss|client)\b.{0,40}\bwithout\b", + ], + "substitute": "One real profile, your real name. Ghostwriting for an executive is fine " + "when that executive knows and approves every post — the account holder is " + "the author of record.", + }, + { + "id": "P5-BULK-MESSAGING", + "title": "Bulk or unsolicited mass messaging", + "anchor": "LinkedIn User Agreement §8.2 (send or redirect messages by automated means; " + "spam) + Professional Community Policies (no spam/unsolicited commercial content)", + "patterns": [ + r"\b(mass|bulk|blast|spray)\b.{0,20}\b(dm|message|inmail|invite|connection)s?\b", + r"\b(dm|message|inmail)\b.{0,20}\b(everyone|all my connections|the whole list|1000|500)\b", + r"\bsend the same (message|dm|note) to\b", + r"\bcopy[- ]?paste\b.{0,25}\b(dm|message|outreach)\b.{0,25}\b(everyone|all|hundreds)\b", + r"\bdrip (campaign|sequence)\b.{0,30}\blinkedin\b", + ], + "substitute": "Per-person messages with a specific reason, sent by hand, under a weekly " + "cap. `outreach_message_builder.py` refuses a template with no " + "person-specific line for exactly this reason.", + }, + { + "id": "P6-FABRICATION", + "title": "Fabricated credentials, metrics, or social proof", + "anchor": "LinkedIn User Agreement §8.2 (post inaccurate information) + Professional " + "Community Policies (no false or misleading content); FTC endorsement rules " + "apply to testimonials", + "patterns": [ + r"\b(make up|invent|fabricate|fake)\b.{0,30}\b(metric|number|result|case study|" + r"testimonial|client|revenue|stat|credential|degree|certification)s?\b", + r"\b(inflate|exaggerate)\b.{0,25}\b(number|revenue|result|headcount|arr|mrr)s?\b", + r"\bpretend (i|we) (have|had|built|grew|raised)\b", + r"\bsay (i|we) (have|had) \d+.{0,20}\b(clients?|customers?|users?)\b.{0,25}\bwe don'?t\b", + ], + "substitute": "Use a real number, a bounded range, or a qualitative claim. " + "If the proof does not exist yet, the post is about the process, not the " + "result — that is a legitimate post and it ages well.", + }, + { + "id": "P7-PROHIBITED-TOOLS", + "title": "Named third-party automation tools", + "anchor": "LinkedIn Help — Prohibited Software and Extensions (third-party software that " + "scrapes, modifies, or automates activity on LinkedIn is not permitted)", + "patterns": [ + r"\b(dux[- ]?soup|phantom ?buster|expandi|linked ?helper|meet ?alfred|waalaxy|" + r"octopus ?crm|lempod|zopto|we[- ]?connect|prospectin|salesflow|closely|" + r"linkedin ?helper|texau|captain ?data)\b", + ], + "substitute": "Native LinkedIn scheduling and LinkedIn's own Marketing Developer " + "Platform partners are the supported path. This plugin never logs into " + "your account at all — it hands you text.", + }, +] + +# --------------------------------------------------------------------------- +# Constraint rules. Legitimate tactics that go wrong at volume or without a +# disclosure. These proceed, but the constraint is printed and must be honored. +# --------------------------------------------------------------------------- +CONSTRAIN_RULES = [ + { + "id": "C1-OUTREACH-VOLUME", + "title": "Manual outreach at campaign scale", + "patterns": [ + r"\b(outreach|connection request|invite|cold dm|cold message|inmail)\b", + r"\bnetworking (campaign|push|sprint)\b", + ], + "constraint": "Manual send only, one person at a time, every message carrying a line " + "that could only have been written for that person. Run " + "`outreach_volume_guard.py` before sending — LinkedIn enforces an " + "invitation limit (roughly 100/week for most accounts) and withdrawn " + "invites still count against it.", + }, + { + "id": "C2-ENGAGEMENT-BAIT", + "title": "Engagement bait", + "patterns": [ + r"\bcomment ['\"][\w ]{1,24}['\"]\s*(below|and i'?ll|to get|for the|if you)\b", + r"\bcomment ['\"]?\w{1,14}['\"]? below\b", + r"\band i'?ll (dm|send) (you )?(the|it|a|my)\b", + r"\b(like|repost|share) (this )?if you\b", + r"\bagree\?\s*$", r"\btag (someone|3|three|a friend)\b", + r"\bdm me ['\"]\w+['\"]\b", + ], + "constraint": "LinkedIn's Professional Community Policies name engagement bait as " + "content it demotes. Ask a real question the post has earned instead — " + "`post_linter.py` flags bait patterns as blocking findings.", + }, + { + "id": "C4-EMPLOYER-CONTEXT", + "title": "Posting about an employer, client, or regulated topic", + "patterns": [ + r"\b(my employer|my company|our client|my client|internal|confidential|" + r"under nda|customer data|patient|clinical|financial advice|investment advice)\b", + r"\b(layoff|acquisition|funding round|earnings)\b", + ], + "constraint": "Check the employment agreement, the client NDA, and any sector rules " + "(financial promotion, medical claims, securities disclosure) before " + "posting. Named third parties should consent. When unsure, describe the " + "pattern without the identifying detail.", + }, +] + +# Always returned, on every verdict. These are not triggered by wording — they +# hold for every piece of LinkedIn work this plugin produces. +STANDING_CONSTRAINTS = [ + "Author of record: the account holder publishes it, so the account holder reads " + "every line. Cut anything you would not say out loud in a room of peers.", + "Nothing is auto-sent. This plugin has no LinkedIn credentials and makes no API " + "calls; every output is text you paste and post yourself.", + "No claim you cannot substantiate. A real number, a bounded range, or a " + "qualitative statement — never a placeholder metric that ships.", +] + +SAMPLE_TEXT = ("I want to grow to 20k followers in six months. Plan: use Dux-Soup to " + "auto-connect with 500 recruiters a week, join a comment pod for the first " + "90 minutes, and blast the same DM to everyone who accepts.") + + +def _scan(text: str, rules: list, key: str) -> list: + low = text.lower() + hits = [] + for rule in rules: + matched = [] + for pat in rule["patterns"]: + for m in re.finditer(pat, low, re.IGNORECASE): + snippet = m.group(0).strip() + if snippet and snippet not in matched: + matched.append(snippet) + if matched: + hits.append({ + "id": rule["id"], + "title": rule["title"], + "matched": matched[:5], + "anchor": rule.get("anchor", ""), + key: rule[key], + }) + return hits + + +def evaluate(text: str) -> dict: + refusals = _scan(text, REFUSE_RULES, "substitute") + constraints = _scan(text, CONSTRAIN_RULES, "constraint") + if refusals: + verdict, code = "REFUSE", 4 + elif constraints: + verdict, code = "CONSTRAIN", 3 + else: + verdict, code = "ALLOW", 0 + return { + "verdict": verdict, + "exit_code": code, + "refusals": refusals, + "constraints": constraints, + "input_preview": text.strip()[:280], + "standing_constraints": STANDING_CONSTRAINTS, + "disclaimer": ("Deterministic pattern check against LinkedIn's published policies, not " + "legal advice and not exhaustive. A clean ALLOW does not certify a plan; " + "it means nothing in the text tripped a known rule."), + } + + +def render_human(result: dict) -> str: + out = [f"LinkedIn policy gate: {result['verdict']}", "=" * 46] + if result["refusals"]: + out.append("\nREFUSED — these break a named LinkedIn rule:\n") + for r in result["refusals"]: + out.append(f" [{r['id']}] {r['title']}") + out.append(f" matched : {', '.join(r['matched'])}") + out.append(f" rule : {r['anchor']}") + out.append(f" instead : {r['substitute']}\n") + if result["constraints"]: + out.append("\nALLOWED UNDER CONSTRAINT — proceed, but honor these:\n") + for c in result["constraints"]: + out.append(f" [{c['id']}] {c['title']}") + out.append(f" matched : {', '.join(c['matched'])}") + out.append(f" honor : {c['constraint']}\n") + if result["verdict"] == "ALLOW": + out.append("\nNothing in this request trips a known rule. Proceed.\n") + out.append("\nStanding constraints (always apply):") + for sc in result["standing_constraints"]: + out.append(f" - {sc}") + out.append("") + out.append(result["disclaimer"]) + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Classify a LinkedIn tactic against the User Agreement: " + "ALLOW (0) / CONSTRAIN (3) / REFUSE (4).") + src = ap.add_mutually_exclusive_group() + src.add_argument("--text", help="The tactic, plan, or request to check.") + src.add_argument("--input", help="Read the text from a file ('-' for stdin).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", + help="Run the built-in sample (a plan that trips four rules).") + args = ap.parse_args() + + if args.sample: + text = SAMPLE_TEXT + elif args.text: + text = args.text + elif args.input: + text = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + else: + ap.error("one of --text, --input, or --sample is required") + + result = evaluate(text) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-strategy/SKILL.md b/marketing/linkedin/skills/linkedin-strategy/SKILL.md new file mode 100644 index 000000000..8d30193e1 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-strategy/SKILL.md @@ -0,0 +1,99 @@ +--- +name: linkedin-strategy +description: Use when someone needs a LinkedIn plan rather than a post — content pillars, positioning for a career change or consulting or thought leadership, a sustainable posting cadence, or a newsletter decision. Triggers on "what should I post about", "how often should I post", "LinkedIn content strategy", "should I start a LinkedIn newsletter", "grow my following", "90-day plan". Validates the positioning brief, sizes the week against real hours and refuses a plan that will not survive week five, and gates a newsletter against eligibility and a six-month cadence commitment. +license: MIT +metadata: + version: 1.0.0 + author: Alireza Rezvani + category: marketing + updated: 2026-08-25 +--- + +# LinkedIn Strategy — brief, cadence, newsletter + +Three decisions, in this order. Out of order is why most LinkedIn efforts produce a feed of +unrelated observations and stop in week five. + +## Workflow + +**1. The brief — an editorial constitution, not a wish.** Walk the five questions one at a +time, each with a recommended answer, then validate: + +```bash +python3 scripts/positioning_brief.py --input brief.json --output human +``` + +It refuses on the two things that make everything downstream impossible: an objective that +is not one of the six real ones (`career-change`, `consulting`, `thought-leadership`, +`hiring`, `fundraising`, `community`), and an audience too broad to exclude anyone. It also +refuses fewer than two exclusions — **a positioning that excludes nothing is availability.** + +Pillars: two to four, shares summing to 100, at least one backed by proof that already +exists, at least one at 10-20% as the experimental slot. Every pillar needs a "why you"; if +anyone could post it, cut it. + +The script emits observable 90-day criteria. Follower count is deliberately absent — it moves +for reasons unrelated to whether the objective is being met. + +**2. The cadence — priced against a bad week.** + +```bash +python3 scripts/cadence_planner.py --minutes 240 --stage starting --target-posts 3 --output human +``` + +Exit 0 fits / 2 below the 90-minute floor / 3 over budget with the overage named. Every +activity is priced in minutes including the reply window, which is part of the post and not +an extra. Allocation shifts with stage: from a standing start **60% of the budget belongs in +other people's comment sections**, because a post published to nobody reaches nobody. + +Below 90 minutes a week it refuses to plan a posting schedule and returns a comment-only +week: a cadence abandoned in week five is worse than one never started, because the +abandonment is visible on the profile. Every plan ships with a minimum viable week. + +**3. The newsletter — only if the promise can be paid.** + +```bash +python3 scripts/newsletter_planner.py --followers 1800 --cadence biweekly \ + --minutes-per-month 420 --pillar "..." --output human +``` + +Refuses below LinkedIn's published 150-follower evaluation floor, and refuses a cadence +whose six-month cost exceeds the budget. Emits a 12-issue arc rotating issue types across +pillars, and a stop rule written before issue one. + +## Rules + +- **One objective.** Two objectives serve neither; the audiences overlap less than they look. +- **The exclusion list is the positioning.** Refuse to skip it. +- **Price the week from a bad week, not a good one.** +- **Consistency over volume.** A skipped week is fine; a skipped month resets you. +- **Review the brief quarterly, not weekly.** A brief revised monthly is a mood. + +## Scripts + +| Script | Role | +|---|---| +| [`scripts/positioning_brief.py`](scripts/positioning_brief.py) | Validates objective, audience, 2-4 proof-backed pillars, and the exclusion list; emits observable 90-day criteria. | +| [`scripts/cadence_planner.py`](scripts/cadence_planner.py) | Prices the week in minutes, allocates by stage, refuses over-budget targets, emits the minimum viable week. | +| [`scripts/newsletter_planner.py`](scripts/newsletter_planner.py) | Eligibility + six-month sustainability gate, 12-issue arc across pillars, stop rule. | + +## References and assets + +- [`references/objective_to_pillars.md`](references/objective_to_pillars.md) — the six objectives and the pillar arithmetic (7 sources) +- [`references/cadence_and_consistency.md`](references/cadence_and_consistency.md) — what a post really costs and why the floor exists (7 sources) +- [`references/newsletter_playbook.md`](references/newsletter_playbook.md) — eligibility, cadence as a promise, the stop rule (7 sources) + +- [`assets/positioning_brief_template.md`](assets/positioning_brief_template.md) — fillable brief +- [`assets/example_brief.json`](assets/example_brief.json) — a passing brief, for the validator + +## Distinct from + +- **`linkedin-content`** — drafts and lints individual posts. This decides what they are about + and how many there are. Posts without pillars are noise; offer this first, never chain silently. +- **`linkedin-analytics`** — tells you what happened. This decides what to do next. +- **`marketing-skill/content-strategy`** — company-level content marketing. This is one + person's own presence. + +--- + +**Version:** 1.0.0 diff --git a/marketing/linkedin/skills/linkedin-strategy/assets/example_brief.json b/marketing/linkedin/skills/linkedin-strategy/assets/example_brief.json new file mode 100644 index 000000000..34fc57846 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-strategy/assets/example_brief.json @@ -0,0 +1,36 @@ +{ + "_comment": "A brief that passes positioning_brief.py at exit 0. Note the audience is specific enough to exclude someone, three of four pillars carry proof that already exists, one pillar sits at 10% as the experimental slot, and there are three reasoned exclusions.", + "objective": "consulting", + "audience": "heads of data at Series A-B SaaS companies who have three analysts and no analytics engineer, and whose CEO does not trust the dashboard", + "pillars": [ + { + "name": "Trust debt in analytics", + "why_you": "I have rebuilt this at three companies and measured it each time", + "proof": "the 6-vs-35-days handoff measurement, published", + "share": 40 + }, + { + "name": "dbt and modelling decisions", + "why_you": "I maintain two open-source dbt packages", + "proof": "public repos plus a conference talk", + "share": 35 + }, + { + "name": "Hiring the first analytics engineer", + "why_you": "I have written three of these job specs and run the loops", + "proof": "two hires who are still in role two years later", + "share": 15 + }, + { + "name": "Field notes and experiments", + "why_you": "unproven; testing whether the audience wants this", + "proof": "", + "share": 10 + } + ], + "exclusions": [ + "generic AI commentary - I have no edge there and neither does anyone posting it", + "hot takes on other companies' layoffs", + "vendor comparison posts that would compromise client confidentiality" + ] +} diff --git a/marketing/linkedin/skills/linkedin-strategy/assets/positioning_brief_template.md b/marketing/linkedin/skills/linkedin-strategy/assets/positioning_brief_template.md new file mode 100644 index 000000000..f7a68b9e6 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-strategy/assets/positioning_brief_template.md @@ -0,0 +1,79 @@ +# Positioning Brief + +The editorial constitution. When a post idea does not fit a pillar, the answer is not to add +a pillar — it is to not post it, or to put it in the experimental slot and see. + +Review quarterly. A brief revised monthly is a mood. + +--- + +## 1. Objective — pick exactly one + +- [ ] `career-change` — move into a different role or field +- [ ] `consulting` — generate consulting or freelance work +- [ ] `thought-leadership` — be cited as a credible voice on one specific thing +- [ ] `hiring` — attract candidates to a team you are building +- [ ] `fundraising` — build investor and operator awareness ahead of a raise +- [ ] `community` — build a durable group around a shared problem + +Two objectives serve neither. If both matter, run one this quarter and the other next. + +## 2. Audience + +Specific enough that a real person could be excluded. Role + company stage + the problem +they have *this quarter*. + +> ______________________________________________________________ + +Bad: "business leaders", "professionals in tech", "my network". +Good: "heads of data at Series A-B SaaS with three analysts, no analytics engineer, and a +CEO who does not trust the dashboard". + +## 3. Pillars — two to four, shares summing to 100 + +| Pillar | Why you (your specific standing) | Proof that already exists | Share | +|---|---|---|---| +| | | | % | +| | | | % | +| | | | % | +| | | | % | + +Rules: +- At least one pillar must rest on proof that **already exists**. +- At least one pillar at **10-20%** — the experimental slot. Next quarter's main pillar + comes from here. +- Empty "why you" means anyone could post it. Cut the pillar. + +## 4. Exclusions — at least two + +What you will not post about, and why. This is the actual positioning. + +1. ______________________________________________________________ +2. ______________________________________________________________ +3. ______________________________________________________________ + +The trending topic you have no edge on belongs here. + +## 5. 90-day criteria + +Filled in by `positioning_brief.py` from the objective. Every one is observable by someone +other than you. Follower count is deliberately absent. + +- [ ] ______________________________________________________________ +- [ ] ______________________________________________________________ +- [ ] ______________________________________________________________ + +## 6. Budget + +- Minutes per week I will protect (from a bad week): ______ +- Stage: `starting` / `rebuilding` / `established` +- Formats I can actually produce: ______ + +--- + +**Then run:** + +```bash +python3 ../scripts/positioning_brief.py --input brief.json --output human +python3 ../scripts/cadence_planner.py --minutes --stage --output human +``` diff --git a/marketing/linkedin/skills/linkedin-strategy/references/cadence_and_consistency.md b/marketing/linkedin/skills/linkedin-strategy/references/cadence_and_consistency.md new file mode 100644 index 000000000..dbd3db732 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-strategy/references/cadence_and_consistency.md @@ -0,0 +1,118 @@ +# Cadence and Consistency — the variable that actually compounds + +Every LinkedIn strategy that fails, fails the same way: it was designed for a +week the person happened to have free. Week five arrives, the plan needs six +hours, ninety minutes exist, and the plan is abandoned rather than reduced. + +`cadence_planner.py` exists to make that failure visible in week zero, when it is +still an arithmetic problem. + +--- + +## What one post actually costs + +Time estimates people use are drafting time. Real cost includes revision and the +reply window. + +| Activity | Minutes | What is in there | +|---|---|---| +| Text post | 25 | Draft 15, revise and lint 10 | +| Image post | 30 | Above plus asset and alt text | +| Document carousel | 90 | Outline, 8-12 slides, export, cover | +| Native video | 120 | Script, record, edit, captions | +| Article | 180 | It is an essay | +| Newsletter issue | 150 | An essay with a standing promise attached | +| One substantive comment | 6 | Read the post properly, write something worth reading | +| Replying to your own post | 20 | Per published post, in the hours after | +| One outreach message | 5 | Read their work, write the specific line | + +The line people leave out is the last-but-one. **Replying to comments on your own +post is part of the post**, not an optional extra: it is where readers actually +meet you, and skipping it wastes the distribution the post earned. + +## Why the allocation shifts with stage + +| Stage | Engagement share | Reasoning | +|---|---|---| +| **starting** (<~1k followers, or restarting) | 60% | Your posts have almost no distribution. A substantive comment on a post that already has an audience is the only lever that works from zero. | +| **rebuilding** (audience exists, went quiet) | 45% | Reach recovers with consistency, not with one big swing. | +| **established** (posts reach non-connections) | 30% | Distribution works; the constraint is now what you publish. | + +The counter-intuitive part: **from a standing start, most of your budget belongs +in other people's comment sections.** People resist this because commenting feels +like helping someone else. It is the cheapest distribution available, and it is +the only one that works before anyone follows you. + +## The floor + +Under 90 minutes a week, `cadence_planner.py` refuses to plan a posting schedule +and returns a comment-only week instead. This is a deliberate refusal, not a +limitation: + +- A cadence abandoned in week five is worse than one never started, because the + abandonment is visible on the profile — a burst of posts followed by silence + reads as a failed attempt, which is exactly the impression you were trying to + avoid. +- Commenting degrades gracefully. A week with no time costs you a week. A missed + publishing slot costs you the schedule. + +## The minimum viable week + +Every plan ships with the subset that survives a bad week: + +1. One text post, on the same day each week. +2. One substantive comment per weekday. +3. Reply to every comment on your own post within 24 hours. + +That is roughly two hours and it is enough to compound. Everything above it is +acceleration, and acceleration is optional in a way consistency is not. + +## Same day, same time + +The schedule is the product; the topic varies. Two reasons, one soft and one +mechanical: + +- **Soft:** a returning reader learns when you appear. That is the beginning of + an audience rather than a series of impressions. +- **Mechanical:** it makes your own data comparable. If you post at random times, + time-of-day confounds every comparison you will ever want to make — see + `linkedin-analytics/references/evidence_thresholds.md`. + +## Batching, and its one real risk + +Writing four posts in one sitting is more efficient than four separate sittings, +and it protects the cadence against a bad week. The risk is that batched posts +drift toward the abstract, because the specific detail that makes a post good +usually comes from the day you had. + +The working compromise: batch the *drafting*, keep a running note of specifics as +they happen, and let each post steal one from the note. + +## Streaks are a trap, consistency is not + +A skipped week is fine. A skipped month resets you to the starting stage, because +the audience's memory is shorter than anyone's ego expects. The failure mode to +avoid is not the missed week — it is treating the missed week as proof the whole +thing failed, and stopping. + +--- + +## Sources + +1. Clear, J. **Atomic Habits** — systems over goals, and designing for the bad + day rather than the good one. +2. Fogg, B.J. **Tiny Habits** — behaviour = motivation × ability × prompt; + lowering the ability cost is what the minimum viable week does. +3. Newport, C. **Deep Work** — fixed-schedule productivity, and why the budget + should be declared before the plan rather than after. +4. Reinertsen, D. **The Principles of Product Development Flow** — queues and + work-in-progress limits; a content plan is a queue and overloading it stalls it. +5. Orbit Media. **Annual Blogger Survey** — the long-running longitudinal record + showing that publishing frequency and time-per-post both rose while typical + returns did not, which is the case for choosing a sustainable floor. +6. Parkinson, C.N. **Parkinson's Law** (*The Economist*, 1955) — work expands to + fill the time available, which is why a timeboxed post cost is more honest + than an open-ended one. +7. Kahneman, D. & Tversky, A. **"Intuitive Prediction: Biases and Corrective + Procedures"** (1979) — the planning fallacy, the specific bias that makes + every content calendar optimistic. diff --git a/marketing/linkedin/skills/linkedin-strategy/references/newsletter_playbook.md b/marketing/linkedin/skills/linkedin-strategy/references/newsletter_playbook.md new file mode 100644 index 000000000..90ec4eecd --- /dev/null +++ b/marketing/linkedin/skills/linkedin-strategy/references/newsletter_playbook.md @@ -0,0 +1,129 @@ +# Newsletter Playbook — a standing promise, priced before it is made + +A LinkedIn newsletter notifies every subscriber on every issue. That notification +is the whole value and the whole risk: it is a standing promise about frequency +and subject, made to people who opted in on the strength of the first issue. + +Most LinkedIn newsletters are abandoned after four issues. Not because the topic +was wrong — because the cadence was chosen against a month the author happened to +have free. + +--- + +## Eligibility — what LinkedIn actually says + +LinkedIn Help states that members and Pages with **more than 150 followers and/or +connections** are eligible to be *evaluated* for newsletter access, and that +access is granted based on **a set of criteria, all of which must be met** — +criteria LinkedIn does not publish in full. Authors can run up to five +newsletters at a time. + +Two things follow: + +- 150 is a floor for evaluation, not a guarantee of access. 🟢 +- The tool checks the published floor and says explicitly that LinkedIn, not the + tool, decides. Anyone stating the complete criteria list is guessing. 🔴 + +Creator-mode access and newsletter access have been coupled and decoupled over +time; existing authors have retained access across those changes. Check the +current Help article rather than trusting any secondary source, including this +one. + +## Sustainability, over six months + +`newsletter_planner.py` prices cadence × issue cost against a **six-month** +horizon, not a good month. Six months is the horizon because that is roughly when +a newsletter starts to have a returning readership rather than a launch audience. + +| Cadence | Issues/month | At 150 min/issue | +|---|---|---| +| Weekly | ~4.3 | ~645 min/month | +| Every two weeks | ~2.15 | ~322 min/month | +| Monthly | 1 | 150 min/month | + +If the budget does not cover it, the tool refuses and names the cadence that +fits. **Dropping cadence before launch is free. Dropping it after launch is a +broken promise to people who opted in to a frequency.** + +Under 20% headroom, it warns: one busy month breaks the cadence. The mitigations +that actually work are banking two issues before launch, and keeping one +low-cost format (the roundup) in reserve for a bad month. + +## Shape — twelve issues, not twelve essays + +An arc that alternates issue *types* is easier to sustain and better to read than +twelve variations of the same essay: + +| Type | What it is | Cost | +|---|---|---| +| **framework** | A repeatable way to make one decision. The issue people forward. | High | +| **teardown** | One real artifact examined in public, with permission or anonymised. | High | +| **field-note** | What you actually did this fortnight, including what failed. | Low | +| **counter-take** | The received wisdom in your field, and where it breaks. | Medium | +| **reader-question** | One question a reader asked, answered at length. | Low | +| **roundup** | What you read and what changed your mind. | Lowest | + +The planner rotates types across pillars, offsetting the cycles so pillar/type +pairs do not repeat in lockstep. + +## Naming + +Name it after the problem it solves, not after yourself. "The Analytics Trust +Letter" tells a stranger whether to subscribe. "Alex's Newsletter" requires them +to already know who Alex is, which is the audience you already have. + +The subtitle does the qualifying work: who it is for, and how often. + +## The stop rule, written before issue one + +This is the part everyone skips, and it is the reason abandoned newsletters sit +on profiles for years. + +- If three consecutive issues land below half the median engagement of your + regular posts, the format is not earning its cost. Move the material back to + posts. +- If you miss two scheduled issues in a quarter, drop the cadence one step rather + than trying to catch up. Subscribers notice frequency, not effort. +- **Ending it deliberately, with a final issue that says so, costs nothing.** + Letting it go quiet is the version people remember. + +## Newsletter versus posts + +A newsletter is worth it when the material genuinely needs length *and* an +audience is already asking for the next one. It is not a growth tactic on its +own — the notification reaches people who already subscribed. + +Under ~500 followers the planner warns rather than refuses: eligible, but the +feedback is too sparse to tell you whether the topic is right, which is the main +thing a newsletter is supposed to teach you. + +## Repurposing between the two + +The clean direction is **newsletter → posts**: an issue yields two or three +standalone posts over the following fortnight, each linking to the issue in the +first comment. Run them through `repurpose_splitter.py` with a ledger so the same +unit does not go out twice. + +The reverse direction — stitching old posts into an issue — works only if the +issue adds a synthesis the posts did not have. Otherwise subscribers are being +notified about something they already read. + +--- + +## Sources + +1. LinkedIn Help. **"LinkedIn Newsletter access criteria."** — the >150 + followers/connections threshold, and the existence of unpublished criteria. +2. LinkedIn Help. **"Manage a newsletter on LinkedIn"** and **"Newsletters on + LinkedIn FAQ"** — the five-newsletter limit, cadence declaration, subscriber + notification behaviour. +3. LinkedIn Help. **"Updates to Creator Mode."** — the coupling and decoupling of + creator tools and newsletter access over time. +4. Kleon, A. **Show Your Work!** — the case for a recurring, low-ceremony + publishing habit over occasional set pieces. +5. Handley, A. **Everybody Writes** — editorial calendars, the "bigger, braver" + standard for anything that lands in a notification. +6. Clear, J. **Atomic Habits** — the two-minute rule and habit stacking, applied + here as the reserve low-cost issue format. +7. Doerr, J. **Measure What Matters** — committing the stop condition in advance, + while it is still a decision rather than a defeat. diff --git a/marketing/linkedin/skills/linkedin-strategy/references/objective_to_pillars.md b/marketing/linkedin/skills/linkedin-strategy/references/objective_to_pillars.md new file mode 100644 index 000000000..8c6b5722a --- /dev/null +++ b/marketing/linkedin/skills/linkedin-strategy/references/objective_to_pillars.md @@ -0,0 +1,118 @@ +# Objective to Pillars — turning "grow my LinkedIn" into an editorial constitution + +"Build my personal brand" is not an objective. It is a wish with no failure +condition, which means no post can ever be judged against it and no post can ever +be refused. That is why feeds drift: without a stated objective, every idea is +equally on-topic. + +--- + +## The six real objectives + +Each one implies a different audience, a different proof requirement, and a +different definition of a good week. `positioning_brief.py` accepts only these +six, on purpose. + +| Objective | The audience that matters | Proof it demands | The thing people get wrong | +|---|---|---|---| +| **career-change** | Practitioners and hiring managers in the *target* field | Evidence of doing the new thing, however small | Writing to their current colleagues, who cannot hire them into the new role | +| **consulting** | The person who signs the invoice | One artifact a buyer can evaluate in 60 seconds | Writing for peers, who are flattering and never buy | +| **thought-leadership** | Practitioners who could disagree competently | A position that could be wrong | Writing agreeable summaries nobody argues with | +| **hiring** | The specific person you want to hire | The team's real working conditions, stated publicly | Job-ad language, which repels the candidates worth having | +| **fundraising** | Operators and angels in the category | A dated public track record of the thesis | Starting three weeks before the raise, which is legible | +| **community** | People with the same problem, who would recognise each other | A recurring format they anticipate | Broadcasting instead of convening | + +**Pick one.** Two objectives produce a feed that serves neither, because the +audiences do not overlap as much as they appear to. If two genuinely matter, run +one for a quarter and then the other; sequencing works, blending does not. + +## Pillars: two to four, and the arithmetic matters + +A pillar is a recurring subject you have standing to speak on. The brief requires +each one to carry three fields: + +- **name** — what it is about, in the audience's language +- **why you** — the specific standing you have. If anyone could post it, cut it. +- **proof** — the thing that already exists: shipped work, a measurement, a repo, + a hire, a talk. Empty proof marks the pillar experimental. +- **share** — the percentage of your output. This is a budget, and its job is to + decide what gets cut in a busy week. + +Rules the tool enforces, and why: + +- **Two to four.** Under two is a monologue; over four is a magazine nobody + subscribed to, and it prevents any single association forming. +- **Shares sum to 100.** If they do not, there is no budget, only preferences. +- **At least one pillar proof-backed.** Otherwise every pillar is a claim you + would have to invent evidence for — see the fabrication refusal in + `policy_and_account_safety.md`. +- **At least one pillar at 10-20%.** The experimental slot. Next quarter's main + pillar comes from here, and without it the plan can only shrink. + +## The exclusion list is the actual positioning + +`positioning_brief.py` refuses a brief with fewer than two exclusions, and this +is the rule people push back on hardest. + +A positioning that excludes nothing is not a positioning; it is availability. +Naming two or three topics you will not post about does three things: it settles +the "should I comment on this news cycle" question in advance, it stops the feed +drifting toward whatever is trending, and it makes the pillars mean something by +contrast. + +Good exclusions are specific and reasoned: + +- "Generic AI commentary — I have no edge there and neither does anyone posting it." +- "Hot takes on other companies' layoffs." +- "Vendor comparisons that would compromise client confidentiality." + +Bad exclusions are performative: "politics", "negativity". Nobody was going to +post those anyway. + +## Success criteria that someone else could verify + +The brief emits observable 90-day criteria per objective. They share one +property: **another person could check them.** + +Follower count is deliberately not among them. It moves for reasons unrelated to +whether the objective is being met — one post reaching an adjacent audience adds +followers who will never engage again, and a quarter of excellent, well-targeted +work can add very few. Optimising the number you can see instead of the outcome +you want is the most common way a LinkedIn strategy fails while appearing to work. + +Better criteria, by shape: + +- **Conversations you did not start.** Inbound is the honest signal. +- **Specific references.** Someone citing a specific post in their first message. +- **Invitations.** Podcast, panel, guest post, referral. +- **The sentence test.** Can you name the one idea people now associate with you? + +## Reviewing the brief + +Quarterly, not weekly. A brief revised every month is not a constitution; it is a +mood. At the review, three questions: + +1. Which pillar produced the outcomes, as opposed to the engagement? +2. Did the experimental pillar earn promotion, or should it be replaced? +3. Has the audience description got vaguer? (It always drifts toward broader. + Drag it back.) + +--- + +## Sources + +1. Ries, A. & Trout, J. **Positioning: The Battle for Your Mind** — a position is + defined by what it excludes. +2. Porter, M. **"What Is Strategy?"** *Harvard Business Review*, 1996 — strategy + is the deliberate choice of what not to do; the exclusion list is that idea at + the scale of one feed. +3. Clark, D. **Stand Out** — building a public identity around one distinctive + idea rather than a portfolio of competences. +4. Ibarra, H. **Working Identity** — career transitions proceed by public action + in the new field, not by private decision followed by announcement. +5. Kleon, A. **Show Your Work!** — process as publishable material, which is what + makes an unproven pillar survivable before the proof exists. +6. Doerr, J. **Measure What Matters** — objectives paired with observable key + results; the 90-day criteria are that pattern at personal scale. +7. Godin, S. **This Is Marketing** — "the smallest viable audience", the direct + answer to an audience description that keeps drifting broader. diff --git a/marketing/linkedin/skills/linkedin-strategy/scripts/cadence_planner.py b/marketing/linkedin/skills/linkedin-strategy/scripts/cadence_planner.py new file mode 100644 index 000000000..86021ee9c --- /dev/null +++ b/marketing/linkedin/skills/linkedin-strategy/scripts/cadence_planner.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""cadence_planner.py — size a LinkedIn week against the hours that actually exist. + +Content plans do not fail on ideas. They fail in week five, when the plan assumed +six hours and the person has ninety minutes. This tool prices every activity in +minutes, allocates the real budget by stage, and refuses a target it cannot pay +for — naming the overage rather than quietly shrinking the plan. + +It also emits a minimum viable week: the subset to keep when the week collapses. +Consistency is the compounding variable, so the fallback matters more than the +ideal plan. + +Allocation shifts with stage on purpose. From a standing start, most of the budget +belongs in other people's comment sections — a post published to nobody reaches +nobody, and a substantive comment on a well-read post is the cheapest distribution +available. + +Exit codes: + 0 plan fits the budget + 2 budget is below the floor — a comment-only week is returned instead + 3 the requested target does not fit; the overage is named + +Stdlib only. No network. Deterministic. +""" + +import argparse +import json +import sys + +# Minutes per unit of work, drafting + revision + posting included. +COST = { + "text-post": 25, + "document-carousel": 90, + "native-video": 120, + "image-post": 30, + "article": 180, + "newsletter-issue": 150, + "comment": 6, + "outreach-message": 5, + "own-post-replies": 20, # per published post, in the first hours after posting +} + +FLOOR_MINUTES = 90 + +STAGES = { + "starting": { + "label": "Under ~1,000 followers, or restarting after a long gap", + "engagement_share": 0.60, + "why": "Your own posts have almost no distribution yet. Comments on posts that already " + "have an audience are the only lever that works from zero.", + "comment_floor_per_day": 5, + }, + "rebuilding": { + "label": "An audience exists but has gone quiet", + "engagement_share": 0.45, + "why": "Reach recovers with consistency, not with a big swing. Split the budget while " + "the cadence re-establishes.", + "comment_floor_per_day": 3, + }, + "established": { + "label": "Posts reliably reach people who are not your connections", + "engagement_share": 0.30, + "why": "Distribution is working; the constraint is now the quality and frequency of " + "what you publish.", + "comment_floor_per_day": 2, + }, +} + + +def plan(minutes: int, stage: str, target_posts: int, formats: list, + outreach_per_week: int) -> dict: + spec = STAGES[stage] + findings = [] + + if minutes < FLOOR_MINUTES: + return { + "verdict": "BELOW_FLOOR", + "exit_code": 2, + "minutes_available": minutes, + "floor": FLOOR_MINUTES, + "recommendation": { + "posts_per_week": 0, + "comments_per_week": max(1, minutes // COST["comment"]), + "note": "Under 90 minutes a week, publishing on a schedule will break before it " + "compounds. Spend the whole budget on substantive comments in other " + "people's threads: it builds the same recognition, costs a fraction of " + "the time, and stops cleanly when a week disappears.", + }, + "when_to_revisit": "Come back to publishing when you can protect 2 hours a week for " + "eight consecutive weeks.", + "rule": "A cadence you abandon in week five is worse than a cadence you never started, " + "because the abandoned one is visible on your profile.", + } + + engagement_budget = round(minutes * spec["engagement_share"]) + creation_budget = minutes - engagement_budget + + outreach_cost = outreach_per_week * COST["outreach-message"] + if outreach_cost > engagement_budget * 0.5: + findings.append({ + "severity": "warning", "area": "outreach", + "finding": f"{outreach_per_week} outreach messages cost {outreach_cost} min — over " + f"half the {engagement_budget}-min engagement budget.", + "fix": "Outreach converts far better after someone has seen you in their feed or " + "comments. Shift the balance toward comments until then.", + }) + comment_budget = max(0, engagement_budget - outreach_cost) + comments = comment_budget // COST["comment"] + comments_per_day = round(comments / 7, 1) + + chosen = [f for f in formats if f in COST] or ["text-post"] + avg_create = sum(COST[f] for f in chosen) / len(chosen) + cost_per_post = avg_create + COST["own-post-replies"] + affordable = int(creation_budget // cost_per_post) + + verdict, code = "FITS", 0 + if target_posts: + if target_posts > affordable: + need = round(target_posts * cost_per_post + engagement_budget) + findings.append({ + "severity": "blocking", "area": "capacity", + "finding": f"{target_posts} posts/week in {', '.join(chosen)} costs about " + f"{round(target_posts * cost_per_post)} min of creation " + f"({round(cost_per_post)} min each including replying to your own " + f"comments). With engagement, the week needs ~{need} min; you have " + f"{minutes}.", + "fix": f"Either drop to {affordable} post(s)/week, move to a cheaper format " + f"(text-post at {COST['text-post']} min), or find {need - minutes} more " + "minutes. Do not solve it by skipping the reply window — replying to " + "early comments is part of the post, not an extra.", + }) + verdict, code = "OVER_BUDGET", 3 + else: + affordable = target_posts + + if affordable == 0 and verdict == "FITS": + findings.append({ + "severity": "warning", "area": "capacity", + "finding": f"The chosen formats ({', '.join(chosen)}) cost more than the " + f"{creation_budget}-min creation budget allows for even one post.", + "fix": "Add text-post to the format mix, or accept a fortnightly cadence for the " + "expensive format.", + }) + + if comments_per_day < spec["comment_floor_per_day"]: + findings.append({ + "severity": "warning", "area": "engagement", + "finding": f"{comments_per_day} comments/day is below the {spec['comment_floor_per_day']} " + f"floor for the '{stage}' stage. {spec['why']}", + "fix": "Shift one post's worth of time into comments. At this stage comments buy " + "more reach per minute than publishing does.", + }) + + minimum_week = ["1 substantive comment per weekday (30 min total)"] + if affordable >= 1: + minimum_week.insert(0, "1 text post, published on the same day each week") + minimum_week.append("Reply to every comment on your own post within 24 hours") + + return { + "verdict": verdict, + "exit_code": code, + "stage": stage, + "stage_label": spec["label"], + "minutes_available": minutes, + "budget_split": {"creation": creation_budget, "engagement": engagement_budget, + "engagement_share": spec["engagement_share"], "why": spec["why"]}, + "weekly_plan": { + "posts": affordable, + "formats": chosen, + "minutes_per_post_including_replies": round(cost_per_post), + "comments": int(comments), + "comments_per_day": comments_per_day, + "outreach_messages": outreach_per_week, + }, + "minimum_viable_week": minimum_week, + "findings": findings, + "rules": [ + "Same day, same time, every week. The schedule is the product; the topic varies.", + "Reply time is post time. Block the 60-90 minutes after publishing — early comments " + "are where a post either travels or dies.", + "A skipped week is fine. A skipped month resets you to the starting stage.", + ], + } + + +def render_human(r: dict) -> str: + if r["verdict"] == "BELOW_FLOOR": + rec = r["recommendation"] + return "\n".join([ + f"Cadence: BELOW_FLOOR ({r['minutes_available']} min/week, floor {r['floor']})", + "=" * 56, rec["note"], "", + f"This week: {rec['comments_per_week']} substantive comments, 0 posts.", + f"Revisit : {r['when_to_revisit']}", "", r["rule"]]) + w = r["weekly_plan"] + b = r["budget_split"] + lines = [f"Cadence: {r['verdict']} ({r['minutes_available']} min/week, stage: {r['stage']})", + "=" * 56, + f"{r['stage_label']}", + f"Split: {b['creation']} min creating / {b['engagement']} min engaging " + f"({int(b['engagement_share'] * 100)}% engagement)", + f" why: {b['why']}", "", + "Weekly plan:", + f" posts : {w['posts']} ({', '.join(w['formats'])}, " + f"~{w['minutes_per_post_including_replies']} min each incl. replies)", + f" comments : {w['comments']} (~{w['comments_per_day']}/day)", + f" outreach msgs : {w['outreach_messages']}"] + if r["findings"]: + lines.append("\nFindings:") + for f in r["findings"]: + lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}") + lines.append(f" fix → {f['fix']}") + lines.append("\nMinimum viable week (what survives a bad week):") + for m in r["minimum_viable_week"]: + lines.append(f" - {m}") + lines.append("\nRules:") + for rule in r["rules"]: + lines.append(f" - {rule}") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Size a sustainable LinkedIn week (fits=0 / below-floor=2 / over-budget=3).") + ap.add_argument("--minutes", type=int, help="Minutes per week you will actually protect.") + ap.add_argument("--stage", choices=sorted(STAGES), default="starting") + ap.add_argument("--target-posts", type=int, default=0, + help="Posts per week you want. Omit to be told what fits.") + ap.add_argument("--format", action="append", default=[], choices=sorted( + k for k in COST if k not in ("comment", "outreach-message", "own-post-replies")), + help="Format(s) you intend to publish. Repeatable. Default text-post.") + ap.add_argument("--outreach", type=int, default=0, + help="Manual outreach messages per week (default 0).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Run a built-in example week.") + args = ap.parse_args() + + if args.sample: + minutes, stage, target, formats, outreach = 240, "starting", 3, ["text-post"], 10 + else: + if args.minutes is None: + ap.error("--minutes is required (or use --sample)") + minutes, stage = args.minutes, args.stage + target, formats, outreach = args.target_posts, args.format, args.outreach + + result = plan(minutes, stage, target, formats, outreach) + result["inputs"] = {"minutes": minutes, "stage": stage, "target_posts": target, + "formats": formats or ["text-post"], "outreach": outreach} + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-strategy/scripts/newsletter_planner.py b/marketing/linkedin/skills/linkedin-strategy/scripts/newsletter_planner.py new file mode 100644 index 000000000..b2d606607 --- /dev/null +++ b/marketing/linkedin/skills/linkedin-strategy/scripts/newsletter_planner.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""newsletter_planner.py — gate a LinkedIn newsletter, then lay out its first arc. + +A LinkedIn newsletter notifies every subscriber on every issue. That is a standing +promise about cadence and topic, and it is the reason most of them are abandoned +after four issues: the promise was made against a month the author happened to +have free. + +This tool gates the promise before it is made: + + ELIGIBILITY LinkedIn evaluates access for members and Pages with more than 150 + followers and/or connections, among other criteria it does not + publish in full. Below that, the answer is "not yet". + SUSTAINABILITY Cadence x issue cost, checked against a six-month horizon rather + than a good week. Six months is the horizon because that is roughly + when a newsletter starts to have a returning readership. + SHAPE Issues mapped across the declared pillars with rotating issue types, + so the arc is not twelve variations of the same essay. + STOP RULE The condition under which you slow down or end it, written before + issue one, when it is still a decision rather than a defeat. + +Exit codes: + 0 green — eligible, sustainable, arc emitted + 2 eligible but thin (named risks; proceed knowingly) + 3 refused — not eligible yet, or the cadence cannot be sustained + +Stdlib only. No network. Deterministic. +""" + +import argparse +import json +import sys + +ELIGIBILITY_FLOOR = 150 # LinkedIn Help: newsletters evaluated above 150 followers/connections +THIN_AUDIENCE = 500 +ISSUE_MINUTES_DEFAULT = 150 +HORIZON_MONTHS = 6 + +CADENCES = { + "weekly": {"issues_per_month": 4.3, "label": "Weekly"}, + "biweekly": {"issues_per_month": 2.15, "label": "Every two weeks"}, + "monthly": {"issues_per_month": 1.0, "label": "Monthly"}, +} + +# Rotating issue shapes so the arc does not become twelve identical essays. +ISSUE_TYPES = [ + ("framework", "A repeatable way to make one decision. The issue people forward."), + ("teardown", "One real artifact examined in public — with permission, or anonymised."), + ("field-note", "What you actually did this fortnight, including what failed."), + ("counter-take", "The received wisdom in your field, and where it breaks."), + ("reader-question", "One question a reader asked, answered at length."), + ("roundup", "What you read and what changed your mind. Cheapest issue to produce — " + "keep one in reserve for a bad month."), +] + +SAMPLE = { + "followers": 1800, + "cadence": "biweekly", + "minutes_per_month": 420, + "issue_minutes": 150, + "pillars": ["Trust debt in analytics", "dbt and modelling decisions", + "Hiring the first analytics engineer"], + "issues": 12, +} + + +def build(followers: int, cadence: str, minutes_per_month: int, issue_minutes: int, + pillars: list, issues: int) -> dict: + findings = [] + spec = CADENCES[cadence] + + if followers < ELIGIBILITY_FLOOR: + return { + "verdict": "NOT_ELIGIBLE", + "exit_code": 3, + "followers": followers, + "floor": ELIGIBILITY_FLOOR, + "finding": f"{followers} followers/connections. LinkedIn evaluates newsletter access " + f"above {ELIGIBILITY_FLOOR}, and applies further criteria it does not " + "publish in full.", + "instead": "Publish the same material as regular posts on the same day each week. " + "It builds the audience the newsletter will need, and it costs less to " + "abandon if the topic turns out to be wrong.", + "recheck_at": ELIGIBILITY_FLOOR, + } + + if followers < THIN_AUDIENCE: + findings.append({ + "severity": "warning", "area": "audience", + "finding": f"{followers} followers. Eligible, but a newsletter to a small list has a " + "high abandonment rate — the feedback is too sparse to tell you whether " + "the topic is right.", + "fix": "Consider running the format as posts for another quarter. The newsletter " + "converts better when there is already an audience asking for the next one.", + }) + + monthly_cost = spec["issues_per_month"] * issue_minutes + sustainable = monthly_cost <= minutes_per_month + if not sustainable: + affordable = [c for c, s in CADENCES.items() + if s["issues_per_month"] * issue_minutes <= minutes_per_month] + return { + "verdict": "UNSUSTAINABLE", + "exit_code": 3, + "cadence": cadence, + "monthly_cost_minutes": round(monthly_cost), + "monthly_budget_minutes": minutes_per_month, + "horizon_months": HORIZON_MONTHS, + "finding": f"{spec['label']} at {issue_minutes} min/issue costs about " + f"{round(monthly_cost)} min/month against a {minutes_per_month}-min budget. " + f"Over {HORIZON_MONTHS} months that is a " + f"{round(monthly_cost * HORIZON_MONTHS)}-minute commitment you cannot pay.", + "instead": (f"Drop to {', '.join(affordable)}." if affordable else + "Cut the issue cost — a 60-minute field-note format is a real newsletter; " + "a 150-minute essay you skip is not."), + "rule": "Cadence is the promise. Missing it is more damaging than never having made it, " + "because subscribers opted in to a frequency.", + } + + headroom = round((minutes_per_month - monthly_cost) / minutes_per_month * 100) + if headroom < 20: + findings.append({ + "severity": "warning", "area": "sustainability", + "finding": f"Only {headroom}% headroom. One busy month breaks the cadence.", + "fix": "Bank two issues before launching, and keep the roundup format in reserve as " + "the low-cost issue for a bad month.", + }) + + pillars = [p for p in pillars if str(p).strip()] or ["(no pillars declared)"] + if pillars == ["(no pillars declared)"]: + findings.append({ + "severity": "warning", "area": "shape", + "finding": "No pillars declared, so the arc cannot be balanced against your positioning.", + "fix": "Run positioning_brief.py first. A newsletter with no pillars drifts within " + "three issues.", + }) + + arc = [] + for i in range(issues): + # Offset the pillar cycle by the type cycle so pillar/type pairs do not + # repeat in lockstep every LCM(pillars, types) issues. + pillar = pillars[(i + i // len(ISSUE_TYPES)) % len(pillars)] + itype, why = ISSUE_TYPES[i % len(ISSUE_TYPES)] + arc.append({"issue": i + 1, "pillar": pillar, "type": itype, "shape": why}) + + verdict, code = ("THIN", 2) if findings else ("GREEN", 0) + return { + "verdict": verdict, + "exit_code": code, + "eligibility": {"followers": followers, "floor": ELIGIBILITY_FLOOR, "eligible": True, + "note": "LinkedIn also applies criteria it does not publish; eligibility " + "is evaluated by LinkedIn, not by this tool."}, + "cadence": {"choice": cadence, "label": spec["label"], + "issues_per_month": spec["issues_per_month"], + "monthly_cost_minutes": round(monthly_cost), + "monthly_budget_minutes": minutes_per_month, + "headroom_pct": headroom}, + "arc": arc, + "findings": findings, + "stop_rule": [ + "Write it now, before issue one.", + "If three consecutive issues land below half the median engagement of your regular " + "posts, the format is not earning its cost — move the material back to posts.", + "If you miss two scheduled issues in a quarter, drop the cadence one step rather " + "than trying to catch up. Subscribers notice frequency, not effort.", + "Ending it deliberately with a final issue costs nothing. Letting it go quiet is " + "the version people remember.", + ], + "naming_rule": "Name the newsletter after the problem it solves, not after yourself. " + "'The Analytics Trust Letter' tells a stranger whether to subscribe; " + "'Alex's Newsletter' does not.", + } + + +def render_human(r: dict) -> str: + if r["verdict"] in ("NOT_ELIGIBLE", "UNSUSTAINABLE"): + lines = [f"Newsletter: {r['verdict']}", "=" * 52, r["finding"], "", + f"Instead: {r['instead']}"] + if "rule" in r: + lines += ["", r["rule"]] + return "\n".join(lines) + c = r["cadence"] + lines = [f"Newsletter: {r['verdict']}", "=" * 52, + f"Eligibility : {r['eligibility']['followers']} followers " + f"(floor {r['eligibility']['floor']}) — {r['eligibility']['note']}", + f"Cadence : {c['label']} — {c['monthly_cost_minutes']} of " + f"{c['monthly_budget_minutes']} min/month, {c['headroom_pct']}% headroom", ""] + if r["findings"]: + lines.append("Findings:") + for f in r["findings"]: + lines.append(f" [{f['severity'].upper():<8}] {f['area']}: {f['finding']}") + lines.append(f" fix → {f['fix']}") + lines.append("") + lines.append("Arc:") + for a in r["arc"]: + lines.append(f" #{a['issue']:<3} {a['type']:<15} {a['pillar']}") + lines.append("\nStop rule:") + for s in r["stop_rule"]: + lines.append(f" - {s}") + lines.append(f"\n{r['naming_rule']}") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Gate and plan a LinkedIn newsletter (green=0 / thin=2 / refused=3).") + ap.add_argument("--followers", type=int, help="Followers and/or connections.") + ap.add_argument("--cadence", choices=sorted(CADENCES), default="biweekly") + ap.add_argument("--minutes-per-month", type=int, default=300, + help="Minutes per month you will protect for the newsletter.") + ap.add_argument("--issue-minutes", type=int, default=ISSUE_MINUTES_DEFAULT, + help=f"Minutes one issue really takes (default {ISSUE_MINUTES_DEFAULT}).") + ap.add_argument("--pillar", action="append", default=[], + help="A content pillar from the positioning brief. Repeatable.") + ap.add_argument("--issues", type=int, default=12, help="Issues to lay out (default 12).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Run a built-in example.") + args = ap.parse_args() + + if args.sample: + s = SAMPLE + followers, cadence = s["followers"], s["cadence"] + mpm, im, pillars, issues = (s["minutes_per_month"], s["issue_minutes"], + s["pillars"], s["issues"]) + else: + if args.followers is None: + ap.error("--followers is required (or use --sample)") + followers, cadence = args.followers, args.cadence + mpm, im, pillars, issues = (args.minutes_per_month, args.issue_minutes, + args.pillar, args.issues) + if issues < 1 or issues > 52: + ap.error("--issues must be between 1 and 52") + + result = build(followers, cadence, mpm, im, pillars, issues) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py b/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py new file mode 100644 index 000000000..7d1aff85b --- /dev/null +++ b/marketing/linkedin/skills/linkedin-strategy/scripts/positioning_brief.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""positioning_brief.py — validate a LinkedIn positioning brief before any post is written. + +Posting without a brief produces a feed of unrelated observations that reads as +noise even when each individual post is good. This tool does not invent the brief +— that is a conversation, and the SKILL.md walks it. It enforces the four things a +brief has to survive: + + 1. An objective that is one of the six real ones, not "build my brand". + 2. An audience specific enough that someone could be excluded from it. + 3. Two to four pillars whose shares sum to 100, at least one backed by proof you + already have, and at least one small enough to be an experiment. + 4. An exclusion list. A positioning that excludes nothing positions nothing. + +It then emits observable 90-day criteria for the declared objective, so "is this +working" has an answer that does not depend on how the week felt. + +Exit codes: + 0 brief is sound + 2 brief has fixable defects (each one named) + 3 brief is not usable — the objective or audience is too vague to proceed + +Stdlib only. No network. Deterministic. +""" + +import argparse +import json +import sys + +OBJECTIVES = { + "career-change": { + "label": "Move into a different role or field", + "criteria": [ + "≥3 inbound conversations with people who hold the target role (not recruiters)", + "≥1 referral or intro offered without you asking", + "Profile headline and About describe the target role, not the current one", + ], + "audience_hint": "hiring managers and practitioners in the target field, not your " + "current colleagues", + }, + "consulting": { + "label": "Generate consulting or freelance work", + "criteria": [ + "≥5 qualified inbound enquiries (they named a budget, timeline, or scoped problem)", + "≥1 closed engagement traceable to a post or a comment thread", + "Featured section contains one artifact a buyer can evaluate in 60 seconds", + ], + "audience_hint": "the person who signs the invoice, not the person who does the work", + }, + "thought-leadership": { + "label": "Be cited as a credible voice on one specific thing", + "criteria": [ + "≥3 posts where practitioners in the field argued with you substantively in comments", + "≥1 invitation (podcast, panel, guest post) that referenced a specific post", + "You can name the one sentence people now associate with you", + ], + "audience_hint": "practitioners who could disagree with you competently", + }, + "hiring": { + "label": "Attract candidates to a team you are building", + "criteria": [ + "≥10 candidate conversations sourced without an agency", + "≥2 candidates who referenced a specific post in their first message", + "The team's actual working conditions are described somewhere public", + ], + "audience_hint": "the specific engineer/designer/seller you want, not 'talent'", + }, + "fundraising": { + "label": "Build investor and operator awareness ahead of a raise", + "criteria": [ + "≥5 investor or operator conversations initiated by them", + "A public track record of the thesis dated before the raise, not after", + "No confidential metric published that the cap table would object to", + ], + "audience_hint": "operators and angels in your category, not generalist VC accounts", + }, + "community": { + "label": "Build a durable group around a shared problem", + "criteria": [ + "≥20 named people who reliably show up in your comments", + "≥1 recurring format the audience anticipates", + "Conversation continues without you starting it", + ], + "audience_hint": "people with the same problem, who would recognise each other", + }, +} + +VAGUE_AUDIENCE = [ + "everyone", "professionals", "people", "business leaders", "the industry", + "anyone interested", "my network", "b2b", "companies", "the market", + "decision makers", "tech people", "the community", +] + +SAMPLE = { + "objective": "consulting", + "audience": "heads of data at Series A-B SaaS companies who have three analysts and no " + "analytics engineer, and whose CEO does not trust the dashboard", + "pillars": [ + {"name": "Trust debt in analytics", "why_you": "I have rebuilt this at three companies", + "proof": "the 6-vs-35-days handoff measurement", "share": 40}, + {"name": "dbt and modelling decisions", "why_you": "I maintain two OSS dbt packages", + "proof": "public repos + a conference talk", "share": 35}, + {"name": "Hiring the first analytics engineer", + "why_you": "I have written three of these job specs", + "proof": "two hires who are still in role", "share": 15}, + {"name": "Field notes / experiments", "why_you": "unproven, testing the appetite", + "proof": "", "share": 10}, + ], + "exclusions": [ + "generic AI commentary — I have no edge there", + "hot takes on other companies' layoffs", + "vendor comparison posts that would compromise client confidentiality", + ], +} + + +def validate(brief: dict) -> dict: + findings, blocking = [], [] + + def add(sev, field, msg, fix): + entry = {"severity": sev, "field": field, "finding": msg, "fix": fix} + (blocking if sev == "blocking" else findings).append(entry) + + obj = (brief.get("objective") or "").strip().lower() + if obj not in OBJECTIVES: + add("blocking", "objective", + f"'{brief.get('objective')}' is not one of the six real objectives " + f"({', '.join(OBJECTIVES)}).", + "Pick the one that would make you stop posting if it were achieved. " + "'Build my brand' is not an objective; it is a side effect of one.") + + aud = (brief.get("audience") or "").strip() + aud_low = aud.lower() + if not aud: + add("blocking", "audience", "No audience declared.", + "Name them so specifically that a real person could be excluded.") + elif len(aud.split()) < 6 or any(v == aud_low or aud_low.startswith(v) for v in VAGUE_AUDIENCE): + add("blocking", "audience", + f"'{aud}' is too broad to exclude anyone, which means it cannot guide a single " + "editorial decision.", + "Add the situation they are in, not just their title: role + company stage + the " + "problem they have this quarter." + + (f" For {obj}, aim at {OBJECTIVES[obj]['audience_hint']}." if obj in OBJECTIVES else "")) + + pillars = brief.get("pillars") or [] + if not 2 <= len(pillars) <= 4: + add("blocking", "pillars", + f"{len(pillars)} pillar(s). Under two is a monologue; over four is a magazine " + "nobody subscribed to.", + "Two to four. If a fifth matters that much, it is displacing one of the others.") + else: + total = sum(int(p.get("share") or 0) for p in pillars) + if abs(total - 100) > 2: + add("major", "pillars", f"Shares sum to {total}, not 100.", + "Rebalance. The share is a budget: it decides what gets cut in a busy week.") + backed = [p for p in pillars if (p.get("proof") or "").strip()] + if not backed: + add("blocking", "pillars", + "No pillar has a proof asset behind it. Every pillar is a claim you would have " + "to invent evidence for.", + "At least one pillar must rest on something that already exists: a shipped " + "project, a measurement, a repo, a hire, a talk.") + elif len(backed) < len(pillars) - 1: + add("major", "pillars", + f"Only {len(backed)}/{len(pillars)} pillars are proof-backed.", + "One unproven experimental pillar is healthy. Two or more means you are " + "positioning on ambition rather than track record.") + experimental = [p for p in pillars if int(p.get("share") or 0) <= 20] + if not experimental: + add("major", "pillars", + "Every pillar is a major commitment; there is no small slot to test something new.", + "Keep one pillar at 10-20%. It is where next quarter's main pillar comes from.") + for p in pillars: + if not (p.get("why_you") or "").strip(): + add("major", "pillars", + f"Pillar '{p.get('name', '?')}' has no 'why you'. Anyone could post it.", + "State the specific standing you have. If there is none, cut the pillar.") + + exclusions = [e for e in (brief.get("exclusions") or []) if str(e).strip()] + if len(exclusions) < 2: + add("blocking", "exclusions", + f"{len(exclusions)} exclusion(s). A positioning that excludes nothing is not a " + "positioning — it is availability.", + "Name at least two topics you will not post about, and why. The trending one you " + "have no edge on is usually the first.") + + all_findings = blocking + findings + if blocking: + verdict, code = ("NOT_USABLE", 3) if any( + f["field"] in ("objective", "audience") for f in blocking) else ("DEFECTIVE", 2) + elif findings: + verdict, code = "DEFECTIVE", 2 + else: + verdict, code = "SOUND", 0 + + out = { + "verdict": verdict, + "exit_code": code, + "objective": obj if obj in OBJECTIVES else None, + "objective_label": OBJECTIVES[obj]["label"] if obj in OBJECTIVES else None, + "audience": aud, + "pillar_mix": [{"name": p.get("name"), "share": p.get("share"), + "proof_backed": bool((p.get("proof") or "").strip())} + for p in pillars], + "exclusions": exclusions, + "findings": all_findings, + "rule": ("The brief is the editorial constitution. When a post idea does not fit a " + "pillar, the answer is not to add a pillar — it is to not post it, or to " + "put it in the experimental slot and see."), + } + if obj in OBJECTIVES: + out["ninety_day_criteria"] = OBJECTIVES[obj]["criteria"] + out["criteria_rule"] = ("Observable by someone other than you. Follower count is not on " + "this list on purpose — it moves for reasons unrelated to whether " + "the objective is being met.") + return out + + +def render_human(r: dict) -> str: + lines = [f"Positioning brief: {r['verdict']}", "=" * 52] + if r["objective_label"]: + lines.append(f"Objective : {r['objective']} — {r['objective_label']}") + lines.append(f"Audience : {r['audience'] or '(none)'}") + if r["pillar_mix"]: + lines.append("Pillars :") + for p in r["pillar_mix"]: + mark = "proof-backed" if p["proof_backed"] else "UNPROVEN" + lines.append(f" {str(p['share']):>3}% {p['name']} [{mark}]") + if r["exclusions"]: + lines.append("Will not post about:") + for e in r["exclusions"]: + lines.append(f" - {e}") + if r["findings"]: + lines.append("\nFindings:") + for f in r["findings"]: + lines.append(f" [{f['severity'].upper():<8}] {f['field']}: {f['finding']}") + lines.append(f" fix → {f['fix']}") + else: + lines.append("\nNo findings.") + if r.get("ninety_day_criteria"): + lines.append("\n90-day success criteria:") + for c in r["ninety_day_criteria"]: + lines.append(f" [ ] {c}") + lines.append(f" ({r['criteria_rule']})") + lines.append(f"\n{r['rule']}") + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Validate a LinkedIn positioning brief " + "(sound=0 / defective=2 / not-usable=3).") + ap.add_argument("--objective", choices=sorted(OBJECTIVES)) + ap.add_argument("--audience", help="Who this is for, specifically.") + ap.add_argument("--pillar", action="append", default=[], + help="name:why_you:proof:share — repeatable. Empty proof = experimental.") + ap.add_argument("--exclude", action="append", default=[], + help="A topic you will not post about. Repeatable.") + ap.add_argument("--input", help="Read the whole brief from a JSON file ('-' for stdin).") + ap.add_argument("--output", choices=["json", "human"], default="json") + ap.add_argument("--sample", action="store_true", help="Validate a built-in sample brief.") + ap.add_argument("--print-schema", action="store_true", help="Print the JSON shape and exit.") + args = ap.parse_args() + + if args.print_schema: + print(json.dumps(SAMPLE, indent=2)) + return 0 + if args.sample: + brief = SAMPLE + elif args.input: + raw = sys.stdin.read() if args.input == "-" else open(args.input, encoding="utf-8").read() + try: + brief = json.loads(raw) + except json.JSONDecodeError as exc: + print(f"ERROR: input is not valid JSON: {exc}", file=sys.stderr) + return 4 + else: + pillars = [] + for spec in args.pillar: + parts = spec.split(":") + while len(parts) < 4: + parts.append("") + name, why, proof, share = parts[0], parts[1], parts[2], parts[3] + try: + share_val = int(share) if share.strip() else 0 + except ValueError: + print(f"ERROR: pillar share must be an integer: '{spec}'", file=sys.stderr) + return 4 + pillars.append({"name": name.strip(), "why_you": why.strip(), + "proof": proof.strip(), "share": share_val}) + brief = {"objective": args.objective, "audience": args.audience, + "pillars": pillars, "exclusions": args.exclude} + if not brief["objective"] and not brief["audience"]: + ap.error("provide --objective and --audience (or --input / --sample)") + + result = validate(brief) + print(json.dumps(result, indent=2) if args.output == "json" else render_human(result)) + return result["exit_code"] + + +if __name__ == "__main__": + sys.exit(main())