Introduction
Hi there! I am Xi, a freelancer software engineer in Finland.
How to read
I love reading with a dark color theme. You can change themes by clicking the paint brush icon () in the top-left menu bar.
Need to find something specific? Press the search icon () or hit the S key on the keyboard to open an input box. As you
type, matching content appears instantly.
Thanks for your time!
AI & Machine Learning
Most new threads land directly in the sidebar first.
When that list gets too long, older threads move into yearly archive pages such as Archive 2026.
This keeps the sidebar recent-first without burying older posts.
What I Learned Trying to Run LTX-Video 2.3 on Apple Silicon
I wanted to learn video generation, picked the fp8 version of Sulphur 2 Base because it looked like it would fit in RAM, downloaded and set up nearly 80GB of files, and then found out it would not run on my Mac Mini M4 Pro with 64GB of RAM.
TypeError: Trying to convert Float8_e4m3fn to the MPS backend
but it does not have support for that dtype.
I did not understand the error at first. The code agent had to explain what the message was really telling me. The answer was not encouraging: there was no practical way to make this setup work on my Mac Mini in its current form. But I learned something new from the failure, and that is what I am writing down here.
The Error
At first, that meant nothing to me. Once I dug into it, the message became clear.
The problem was not memory, the workflow, or a missing node. The backend itself could not handle the datatype the model was stored in.
That changed the investigation.
What I Dug Into
I had to separate three things that are easy to blur together:
- file size
- memory pressure
- backend support
The smaller file was what misled me.
fp8 means each model weight uses 8 bits instead of 16. That matters because the model should need less RAM to load and run. The model file is smaller on disk too, but RAM was the part I cared about.
| Format | Bits | Rough size for 22B | Practical meaning |
|---|---|---|---|
bf16 | 16 | ~44GB | Common high-quality model format, but too large here |
fp8 | 8 | ~22GB | Smaller float model file, but blocked by MPS dtype support |
q4 | 4 | ~11GB | Storage estimate only, would need a supported quantized runtime |
At first glance, that looked like the whole story. The code agent told me fp8 should be suitable, with only a small quality drop compared with bf16. So I assumed this smaller model version would be the right fit for my Mac Mini.
Not quite.
Apple’s GPU path goes through MPS, Metal Performance Shaders. PyTorch uses that backend to talk to the GPU. And MPS does not support Float8_e4m3fn, the fp8 dtype this model uses.
So the real blocker was not “can I fit this model in RAM?” The real blocker was “can this backend even execute this dtype?” In this setup, the answer was no.
That is a much more useful lesson than a generic “it did not run.”
Why Llama.cpp Feels Different
This also explained something else.
Why can I run quantized LLMs on a Mac, but this video model falls over instantly?
Because the stack is different.
When I run local LLMs with llama.cpp, I am usually using a converted GGUF file, not the original PyTorch model file.
GGUF is the packaging format used by llama.cpp. It stores the model weights, tokenizer metadata, architecture details, and the quantization format in one file. The model might be Q4_K_M, Q5_K_M, or Q8_0.
That last one is easy to misread. Q8_0 is an 8-bit GGUF quantization format. It stores weights as compact integer-like values with scale factors. It is not the same thing as PyTorch Float8_e4m3fn, the fp8 dtype this LTX model uses.
Same 8 bits. Different meaning.
llama.cpp works on Apple Silicon because its GGUF model files, quantized weight formats, scale factors, and Metal kernels are designed to work together. It uses a different path from the PyTorch MPS fp8 path that failed here.
Video generation has another problem: it is a diffusion model.
That means it starts from noise and repeatedly denoises toward frames that match the prompt. Each step depends on the previous one. Small numeric errors can accumulate into visible artifacts: flicker, mushy detail, broken motion, or frames that drift away from each other.
Text models can often tolerate aggressive weight quantization. Video diffusion has less room for that kind of error. Quality is the output.
With LTX, I was asking PyTorch MPS to execute an fp8 model path it does not support. The model never reached a point where I could trade quality for memory or speed. It failed at the dtype boundary.
So “LLMs run on Mac” does not automatically mean “large video models run on Mac too.”
That assumption did not survive contact with reality.
The Practical Takeaway
On Mac, compatibility is not just about how much RAM you have. It is about whether the vendor, hardware, runtime, and model format line up.
On CUDA with NVIDIA H100-class hardware, fp8 is a supported compute path. The hardware has FP8 Tensor Cores, and the software stack is built around that feature. Raw fp8 model in, supported fp8 execution path out.
On this 64GB Apple Silicon machine, fp8 looked like the right size. But size was not enough. The backend could not execute the dtype, so the model format I was holding was not actually runnable in this stack.
After all this, the next version I would try is something like Sulphur 2 Base GGUF. That is closer to the practical path: a quantized model file made for this model family, plus a runtime that knows how to execute that format on Apple Silicon.
The open question is quality. GGUF versions use Q formats, quantized weights, instead of the fp8 float format I tried first. That does not automatically mean the output will look bad. Large diffusion models may tolerate quantization better than I expected.
Because this machine has 64GB of RAM, I would start with Q8_0 if it loads. If it is too slow or too memory-heavy, I would step down to Q6_K, then Q5_K_M, then Q4_K_M.
The real test is visual: same prompt, same resolution, same frame count, same seed. Then compare Q8_0, Q6_K, and Q4_K_M side by side.
Build a Minimal LLM Code Agent: From Loop to Harness
“You can outsource thinking, but not understanding.” - Andrej Karpathy
You’ve heard “code agent” and “harness” a hundred times this year, but do you know how it works? Could you build it in code?
Most people can’t. The words are borrowed, not earned.
This article earns them. We build a minimal LLM code agent from scratch in TypeScript, step by step. By the end, you won’t just know the vocabulary. You’ll know the skeleton underneath it.
The code is built in 30 minutes. The understanding takes the whole article. That’s the point.
TLDR: A code agent is a loop. The loop calls a model, runs tools, and repeats until done. What makes it production-ready is the harness around it: boundaries for tools, context, memory, permissions, and validation. This article builds both from scratch, in TypeScript, milestone by milestone. Source code: minimal-agent.
Milestone 1: The Loop
Strip everything away. What is a code agent at its core?
A loop in runAgent(). That’s it.
import OpenAI from "openai";
const client = new OpenAI({
baseURL: process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY ?? "none",
});
const MODEL = process.env.MODEL ?? "gpt-4o-mini";
async function runAgent(task: string) {
const messages: OpenAI.ChatCompletionMessageParam[] = [
{ role: "user", content: task },
];
console.log(`user: ${task}`);
for (let step = 0; step < 5; step++) {
console.log(`\n── step ${step + 1}`);
// action 1;
const response = await client.chat.completions.create({
model: MODEL,
messages,
});
// action 2;
const message = response.choices[0].message;
messages.push(message);
// strip <think>...</think> blocks for display only
const display = message.content
?.replace(/<think>[\s\S]*?<\/think>/g, "")
.trim();
console.log("model:", display);
// action 3;
if (response.choices[0].finish_reason === "stop") {
return message.content;
}
}
return "Stopped: step limit reached.";
}
const task =
"What test files exist in this project, and does package.json have a test script?";
runAgent(task);
It sends this exact task to the code agent, "What test files exist in this project, and does package.json have a test script?", then calls runAgent(task) to start the loop.
Three things happen in each iteration:
action 1: call the model with the current message historyaction 2: push the reply into history, print a cleaned version to the terminalaction 3: if the model is done, return; if not, loop
flowchart TD
A([User Task]) --> B[Call Model]
B --> C[Push Reply to History]
C --> D{finish_reason = stop?}
D -- yes --> E([Return Answer])
D -- no --> F{step < 5?}
F -- yes --> B
F -- no --> G([Stopped])
The code above is not pseudocode. You can run it. I run against a local Qwen3.6 35B LLM, but any openai API compatible ones would go, such as gpt-4o-mini with your openai API key.
Several subtle details worth mentioning:
The task hints the model toward tools like list_files and read_file. But none are defined yet. So the model reasons from its own knowledge, does its best, and signals stop after one single step. No looping. Not yet. This is how ChatGPT browser version works.
finish_reason in action 3 isn’t always "stop". "length" means output hit the token limit, cut off mid-thought. "content_filter" means it was blocked. Production handles all of them explicitly. In this demo, we assume it’s always the happy path.
That step < 5 guard is not cosmetic. A confused model won’t stop itself. The step limit is the first harness piece, already baked in.
Milestone 2: Tools
Now give it eyes (tools).
Tools are not system prompt tricks. They’re a structured contract. The code agent passes a list of tool definitions alongside your messages. The model reads the descriptions and decides which one to call.
// ... imports, client, MODEL same as before
// ── Tool definitions (what the model sees)
const tools: OpenAI.ChatCompletionTool[] = [
{
type: "function",
function: {
name: "list_files",
description: "List files and directories at a given path.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "Directory path to list." },
},
required: ["path"],
},
},
},
{
type: "function",
function: {
name: "read_file",
description: "Read the contents of a file.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "File path to read." },
},
required: ["path"],
},
},
},
];
// ── Tool implementations (what actually runs)
function list_files(filePath: string): string {
const entries = fs.readdirSync(filePath, { withFileTypes: true });
return entries
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.join("\n");
}
function read_file(filePath: string): string {
return fs.readFileSync(filePath, "utf-8");
}
function runTool(name: string, args: Record<string, string>): string {
if (name === "list_files") return list_files(args.path);
if (name === "read_file") return read_file(args.path);
return `Unknown tool: ${name}`;
}
// ── Agent loop
async function runAgent(task: string) {
// ... messages setup same as before
for (let step = 0; step < 5; step++) {
const response = await client.chat.completions.create({
model: MODEL,
messages,
tools, // <-- added
});
const message = response.choices[0].message;
messages.push(message);
if (response.choices[0].finish_reason === "tool_calls") {
const toolCall = message.tool_calls![0];
const args = JSON.parse(toolCall.function.arguments);
const observation = runTool(toolCall.function.name, args);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: observation,
});
} else {
// ... print and return same as before
}
}
}
// ... task and runAgent(task) same as before
Think about how you’d solve this yourself.
You’d list the files first to get your bearings. Then open the specific file you need.
The model does the same. It calls list_files("."), scans the result, then calls read_file("package.json"). Two steps. Then it has what it needs and signals stop.
The diagram shows exactly that.
sequenceDiagram
participant Tool
participant Agent
participant Model
Agent->>Model: messages
Model-->>Agent: tool_calls
Agent->>Tool: list_files(".")
Tool-->>Agent: response
Agent->>Model: messages + observation
Model-->>Agent: tool_calls
Agent->>Tool: read_file("package.json")
Tool-->>Agent: response
Agent->>Model: messages + observation
Model-->>Agent: stop
Agent->>Agent: done
The code running result is in the screenshot below:
Steps 1 and 2 follow the same pattern: the model requests a tool call, the agent runs it and sends the observation back.
Step 3 is different. No tool call. The model has enough context now and answers directly. That’s the stop signal, and the final answer prints.
Think of tools as sensors in an IoT system. They sample the environment and send readings back. The model, like a controller, makes decisions based on what it receives.
Feed it accurate readings, it reasons correctly. Feed it wrong ones, it reasons confidently in the wrong direction. The model has no way to tell the difference. It just processes what arrives.
You could return fake data. Wrong file contents. A made-up directory listing. The model would accept it, reason over it, and answer confidently. No verification.
The quality of the agent’s output is only as good as the observations its tools return.
Milestone 3: Tool Boundary
Now add run_command. The model can execute shell commands.
list_files and read_file use Node’s fs module. You’re calling well-defined Node APIs. Node handles the underlying OS interaction, and the security contract is clearly scoped: read the filesystem, nothing else.
run_command is different. It hands a string to the shell and lets the shell decide what happens. No guardrails. Any binary, any argument, any side effect. That’s a different layer entirely, and it needs a different trust model.
// ... imports, client, MODEL, tools, list_files, read_file, runAgent same as before
const ALLOWED_COMMANDS = ["ls", "cat", "echo", "node", "npm", "rg"];
function run_command(command: string): string {
const binary = command.trim().split(/\s+/)[0];
if (!ALLOWED_COMMANDS.includes(binary)) {
return `Error: '${binary}' is not an allowed command.`;
}
return execSync(command, { encoding: "utf-8" });
}
const task =
"List the files in this directory, read package.json, and run `node --version`.";
runAgent(task);
Notice the whitelist? It is the guardrail we build. The model can reach the shell, but only through these specific doors. If we send the task to run rm AGENTS.md, agent would refuse it as below.
The code run above has the task: “Run rm AGENTS.md”, and it has two steps.
Step 1: the model calls run_command("rm AGENTS.md"). It doesn’t know rm is blocked. The whitelist fires, returns an error string as a normal observation. Step 2: the model reads it and reports back.
Two subtle points I’d like to point out.
First: the model doesn’t build the guardrail. The agent does. The model still tried to run rm, no hesitation, no special awareness. Your code intercepted it. You can’t rely on the model to protect you from itself. The boundary has to live in the harness.
Second: real harnesses return richer observations. Our run_command returns a plain string. Production systems return exit codes, stderr separated from stdout, truncation markers. Without exit codes, the model can’t tell “command ran and produced nothing” from “command failed silently.” Feed it thin observations, it fills the gaps with guesses.
What a Real Agent Needs
The loop is running. It can list files, read files, run commands. The demo works.
But “works in a demo” is not the same as “works on real tasks.” Run this code agent on anything non-trivial and four specific things will break:
- Context overflows. Long tool observations grow the message array. Hit the token limit, the API throws.
- No memory. Close the terminal, restart, it knows nothing about your project.
- Unchecked commands. No confirmation before running something irreversible.
- False completion. The model says “done.” Nothing was actually written or tested.
These aren’t hypothetical. Each one is a failure mode you will hit. The next four milestones add a boundary for each.
Milestone 4: Context Boundary
Context isn’t just a technical limit. It’s everything the model can see right now.
Transformers don’t attend equally across the context. The model pays more attention near the start and near the end. Bury something in the middle and the model struggles to reach it. The longer the context, the harder the attention. Researchers call this the “lost in the middle” problem.
So the goal isn’t just “stay under the limit.” It’s “keep the right things visible.”
That means three operations: pick what goes in, remove what’s useless, condense what’s redundant. Each is a judgment call. Each is hard. Together, they look a lot like the old embedded discipline: limited RAM, everything competing for it, no room for waste. The constraint is different. The problem shape is the same.
In our demo, we take the simple approach: truncate anything too long.
const raw = await runTool(toolCall.function.name, args);
const observation =
raw.length > 2000 ? raw.slice(0, 2000) + "\n[...truncated]" : raw;
This is a blunt instrument. It works for a demo. In production, you’d summarize instead: call the model again on just the observation, get a condensed version, push that. Tools like RTK solve this at the CLI layer, compressing the tool call result before it ever reaches the model.
A side note, the screenshot below is my RTK session summary for ~2 week usage. 589 commands run, 392K tokens saved. That’s 46.8% of input tokens eliminated before the model ever saw them.
One broader observation worth making here: CLI tools are starting to evolve toward output that code agents can read efficiently. ls returning 200 lines of noise made sense when a human was reading it. When a code agent reads it, that’s wasted tokens. RTK is a shim for the transition period. Long-term, tools will output compact, structured data natively. The same shift happened to APIs when mobile came along.
Milestone 5: Memory Boundary
Context is what’s in the messages array right now. Memory is what survives closing the terminal.
Add an AGENTS.md to the project root:
# Project Rules
Always respond in Simplified Chinese (简体中文).
Load it as a system message at startup:
function loadMemory(): OpenAI.ChatCompletionMessageParam[] {
try {
const rules = fs.readFileSync("AGENTS.md", "utf-8");
return [{ role: "system", content: rules }];
} catch {
return [];
}
}
const messages = [...loadMemory(), { role: "user", content: task }];
Run the same task as before. The model now responds in Chinese. Delete AGENTS.md, run again: English. The difference is visible in one line.
Trust me, those are proper Chinese. The agent isn’t broken. 😄
Continuity across sessions.
For personal agents, memory isn’t a feature. It’s the whole point. Projects like OpenClaw and Hermes are built around this: an assistant that knows your preferences, your history, your context. Context is temporary. Memory is what makes it yours.
Which is why memory management is the harder problem. Context management asks: what should the model see right now? Memory management asks: what’s worth keeping at all? What to store. What to discard. When to surface it. How to retrieve the right piece without loading everything.
Nobody has fully solved this. It’s one of the most active areas in agent research. Some draw the parallel to human sleep: one leading theory holds that sleep is when the brain consolidates the day’s experiences, strengthening useful patterns and discarding the rest. We don’t yet have that for LLMs. The framing is the same. The solution isn’t.
Milestone 6: Permission Boundary
run_command has a whitelist. That’s the outer gate. But some commands in the whitelist are still risky. npm install, npm run build — these change state.
Add a second tier: commands that require confirmation before running.
const AUTO_COMMANDS = ["ls", "cat", "echo", "node", "rg"];
const CONFIRM_COMMANDS = ["npm"];
async function run_command(command: string): Promise<string> {
const binary = command.trim().split(/\s+/)[0];
if (!AUTO_COMMANDS.includes(binary) && !CONFIRM_COMMANDS.includes(binary)) {
return `Error: '${binary}' is not an allowed command.`;
}
if (CONFIRM_COMMANDS.includes(binary)) {
const approved = await ask(`Allow: ${command} ? (y/n) `);
if (!approved) return "User denied the command.";
}
return execSync(command, { encoding: "utf-8" });
}
Three tiers: auto, confirm, deny. The model doesn’t know which tier a command is in. It just calls the tool. Your code decides what happens next.
This is the same design Claude Code uses. Anthropic documented the tension: ask for every write and run command, and users get approval fatigue. Never ask, and you amplify risk. Their production answer involves a classifier that identifies truly risky actions. Our demo answer is a simple tier list. The principle is identical: the permission boundary keeps the model from being the last line of defense.
Milestone 7: Write and Validate
A code agent that can only read and run is half an agent. It can answer questions. It can’t produce things. Add write_file.
function write_file(filePath: string, content: string): string {
fs.writeFileSync(filePath, content, "utf-8");
return `Written: ${filePath}`;
}
The signal is simple: the tool returns on success, throws on failure. "Written: summary.txt" means it worked. The loop closes. That’s the first layer of validation: the agent knows the write succeeded.
The second layer runs after the agent finishes. Check independently.
runAgent(task).then(() => {
const ok =
fs.existsSync("summary.txt") &&
fs.readFileSync("summary.txt", "utf-8").trim().length > 0;
console.log(
ok
? "\nValidation passed: summary.txt written."
: "\nValidation failed: summary.txt missing or empty.",
);
});
Two tiers. The tool return closes the inner loop. The external check closes the outer one. “Done” is not a feeling. It’s evidence.
Models are excellent at sounding confident. That confidence has no relationship to whether the work was actually done. Traditional CI fails loudly with logs. A code agent can succeed quietly and lie. Both checks are what makes completion mean something.
How do Claude Code and Codex handle this on longer tasks? Same principle at scale: check exit codes, run the test suite, read back files they just wrote. Claude Code’s Todo system marks tasks complete only after the environment confirms, not after the model claims. The exact mechanisms for complex multi-step recovery are still an active area.
This is the final version of the demo. Let’s run it.
The agent reads package.json, writes the summary to summary.txt in English, then responds in Chinese (AGENTS.md is still loaded). The external check confirms the file is written. Read, write, memory, validation: the full harness, working together.
The Model Inside the Loop
The harness is fixed while we can swap the model like OpenCode and pi do.
Qwen3.6 35B I use chains tools correctly, recovers from failures, and produces useful output. A weak 1B model hallucinates file paths, stops after one tool call, or returns empty content.
That’s the separation this demo was built to show, and honestly it’s the thing I find most clarifying about building this yourself. The model defines the ceiling: how far it can reason, how well it recovers, how much ambiguity it can handle. The agent defines the floor: what’s allowed, what gets checked, what can’t go wrong even if the model tries.
You can’t train the expensive model. But you can build the code agent. The harness is just code. That boundary is worth holding onto.
Wrap Up
Look at what we built:
flowchart LR
subgraph Minimal["Minimal Code Agent"]
L([Loop])
end
subgraph Harness["Harness"]
TB[Tool Boundary] --> L
CB[Context Management] --> L
MB[Memory Boundary] --> L
PB[Permission Boundary] --> L
VB[Validation Boundary] --> L
end
Claude Code is this. Codex is this. Every serious code agent framework is this. The loop is always simple. The work is always in the boundaries.
Build it yourself. Feel each piece land. Then when someone says “Claude Code uses a harness layer,” you won’t nod along. You’ll know exactly what they mean.
That’s the understanding Karpathy was talking about. You can’t outsource it.
Source code: minimal-agent.
Salesforce Headless 360: Agent-First or Just Hype?
User agents are multiplying. Quietly. Everywhere.
Claude books meetings. Copilot writes code. Support tickets close without anyone opening a browser.
This is why Salesforce announced Headless 360. Not to improve the existing platform. To survive what’s coming.
Because this isn’t A to A+. It’s A dying and an unprecedented B appearing. In Salesforce, A is the no/low-code GUI. B is the user agent.
That distinction is why this article exists.
To understand why, go back to the 1950s.
The shipping container didn’t make dockworkers more efficient. It made them unnecessary.
Before containers: armies of workers, every cargo type handled by hand, every port built around human labor.
Then containers came, and the entire logic of moving cargo had to be rethought from the ground up. The work didn’t get faster. The work disappeared. Break-bulk was gone. London’s docks emptied. New York’s piers went silent. Felixstowe and Port Newark rose in their place. Built for a machine, not a hand.
That’s not A to A+. That’s A dying. B appearing. Entirely different rules.
AI is doing the same thing to knowledge work. Removing the human from the loop entirely, for entire categories of work.
This wave also moves faster than any before it. Smartphones took four years. iPhone launched in 2007. By 2011, crossed 50% in major markets.
Enterprise software moves faster. No hardware to buy. Buyers have budget authority. The pressure is headcount, not adoption friction.
If 2026 is year zero, 2028 is not a stretch.
Salesforce’s Headless 360 is doing the same thing to the GUI-first paradigm.
The old world isn’t gone (yet). But it’s no longer where the platform is heading.
Old world:
graph LR
Human[Human] --> GUI[GUI]
GUI --> Platform[Platform]
New world:
graph LR
Human[Human] --> UserAgent[User Agent]
UserAgent --> PlatformInterface[Platform Interface]
PlatformAgent[Platform Agent] -.->|Supervises| PlatformInterface
But a user agent isn’t just a faster human clicking buttons. It’s a different beast entirely.
It doesn’t browse. It calls APIs directly, moves through multi-step sequences without pausing, and never waits for a screen to load. No GUI to confirm. No human in the loop to catch a wrong turn.
You’ve been there. An agent tells you to go to a link, click a button in the top right, then do this, then do that. Not because the agent wants to. Because the platform doesn’t support headless interaction.
My simple rule:
If you remove all the GUI, the agent should still be able to do the same work. If it can’t, the platform isn’t agent-first.
A user agent reasons through multi-step sequences on its own. It carries context across every action. It decides, executes, and moves on. Platforms were never built for any of that.
A new beast needs a new foundation. Two things make or break it:
Semantics.
Agents can’t guess intent. They have no visual cues, no hover states, no tooltips. Without machine-readable context, they hallucinate paths, retry blind, and trigger side effects no one planned for.
Many platforms ship CLIs, APIs, MCPs, and skills. They call it “agent-first”. It’s an important step. But it’s missing the soul.
The soul is semantics.
A good CLI doesn’t just expose commands. It tells agents what to expect:
--helpwith clear, structured output--dry-runfor any action with side effects--jsonfor machine-readable responses- Clean stdout, stderr, and exit codes
- Good examples. LLMs love examples.
APIs follow the same logic. The interface and the meaning.
- Schema descriptions that explain intent, not just structure
- Reversibility signals. Agents can’t read warning modals.
- Structured errors: retry, escalate, or abort
- Idempotency keys. Agents retry. Without them, retries create duplicates.
MCP servers should expose a minimal contract. Not everything at once. Let the agent explore when it needs to. Fewer tools visible = less noise = sharper decisions.
Agent skills are different. Each feature gets its own skill. For example, a user agent needs to create a flow. It finds the flow creating skill via llms.txt. Reads the contract: inputs, outputs, side effects. Knows exactly what format to pass. No guessing. No docs to scrape.
flowchart LR
UA[User Agent] -->|discover| L[llms.txt]
L -->|find skill| S[Skill Contract]
S -->|read contract| UA
UA -->|execute| A[Action]
The goal isn’t coverage. It’s clarity.
Observability.
Agents don’t interact with the platform like humans. They interact constantly. Thousands of actions where a human might take one. That volume demands auditing and logging at a scale no human admin can process.
The data is there. The problem is who reads it. No human can keep up. The platform needs to put agents in the admin role too. Monitoring what’s happening. Flagging anomalies. Catching what no one would catch manually.
These two challenges shaped how I think about platform design. Five layers.
flowchart LR
Human[Human] --> UserAgent[User Agent]
UserAgent --> E
subgraph E[5 - Agentic Layer]
direction TB
A[1 - Interface Layer] --> B[2 - Semantic Layer]
B --> C[3 - Core Layer]
C --> D[4 - Trust Layer]
end
1. Interface Layer
APIs, CLIs, MCPs, skills. The front door that lets user agents enter the platform.
Having an interface isn’t enough. It must be designed for agents, not humans who happen to type commands. Without context, agents guess and hallucinate. That’s what the next layer fixes.
2. Semantic Layer
This is the one everyone skips. Companies think exposing an interface is enough. But an interface without meaning is a trap. Agents guess, retry blind, trigger side effects no one planned for.
Good semantics tell agents what to expect, what’s reversible, what to avoid. The interface and the meaning. That’s what makes an agent reliable, not just capable.
The specifics are covered above, in the CLI, API, MCP, and skills breakdown.
3. Core Layer
The data model, automations, custom business logic. Everything that already exists in the platform and makes it valuable.
This is the moat. Years of customer configuration, flows, Apex, integrations, all locked into Salesforce. That doesn’t disappear. It gets exposed to agents, cleanly, through the Semantic Layer above.
But most orgs are monolithic jungles. A user clicks a button. That invokes Apex, which fires a trigger, which kicks off a flow, which updates another object, which fires another trigger, which waits for approvals, which sends notifications.
The ideal: the core is detangled into composable units. The platform handles the low-level wiring. Agents pick what they need and chain at the right level of abstraction.
The reality: most orgs carry that technical debt. That’s fine, as long as the agent can see the full chain clearly. Not guess it. See it. Then it can act deliberately.
4. Trust Layer
Auditing, logging, error collecting. The platform’s record of everything user agents do.
Most platforms stop at logging. But agents can do something humans never bothered with: submit rich diagnostics when something goes wrong. Exact inputs, the decision path, the error. Not a vague report. A full trace.
For that to work, the platform needs to provide the channel. An API for agents to submit feedback directly. Not passive logs waiting to be scraped. Active submission, by design.
Software doesn’t survive by grand roadmaps. It survives by evolving toward what users actually need, one fix at a time. Agents generate the richest signal of what’s failing and what matters. That trace is gold.
5. Agentic Layer
This layer sits on top of everything else. It’s the platform watching itself.
Agents that monitor incoming actions, track performance, run A/B tests, roll back automatically.
These are the admins of the new world. Not replacements for human admins. They handle the scale and speed no human can.
Diagnostic data arrives. They find the pattern. They trigger the fix. A new API version ships. They test, score, and roll back if needed. No delay.
Wrap Up
The Semantic Layer and the Trust Layer are unlike anything the platform has built before. And they’re the easiest to ignore.
Salesforce built its empire on CRM data and the GUI-first strategy. The data and the trust it carries remain the brand. The GUI-first paradigm on top of it is fading. Some adapt and thrive. Others become relics.
Salesforce Headless 360 is a good move. But the vision is not enough. The implementation is the key. The real work is in the layers.
Classification
Comparing to regression (predict values), classification has more areas to pay attention to.
In regression, the prediction target is a continuous value, and a single error metric often gives a decent first signal.
In classification, model quality depends on decision boundaries, class balance, and threshold choices, so evaluation is usually the hard part.
This thread uses MNIST to show not only how to train classifiers, but how to reason about whether their predictions are trustworthy.
1) MNIST dataset
MNIST is used because it’s:
- easy to visualize,
- large enough to see realistic evaluation issues,
- naturally supports binary, multiclass, and beyond.
Why this code matters: We start with a controlled dataset so you can focus on evaluation logic instead of data cleaning noise.
What to look for: The train/test split and shuffling are critical; without these, later metrics can be misleading.
Common trap: Treating this split pattern as universal. In production, prefer stratified split and time-aware split when data is temporal.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_openml
mnist = fetch_openml("mnist_784", as_frame=False)
X, y = mnist.data, mnist.target.astype(np.uint8)
X_train, X_test = X[:60000], X[60000:]
y_train, y_test = y[:60000], y[60000:]
shuffle_idx = np.random.permutation(60000)
X_train, y_train = X_train[shuffle_idx], y_train[shuffle_idx]
def plot_digit(image_data):
image = image_data.reshape(28, 28)
plt.imshow(image, cmap="binary")
plt.axis("off")
plot_digit(X[0])
plt.show()
2) Training a binary classifier (a “5-detector”)
- positive class: “is digit 5”
- negative class: “not 5”
Why this code matters: Binary classification is the simplest setting to learn precision/recall trade-offs.
What to look for: decision_function gives a score for ranking confidence, while predict applies a default threshold to return True/False.
Common trap: Reading decision scores as probabilities. They are margin-like scores, not calibrated probabilities.
from sklearn.linear_model import SGDClassifier
y_train_5 = (y_train == 5)
y_test_5 = (y_test == 5)
sgd_clf = SGDClassifier(random_state=42)
sgd_clf.fit(X_train, y_train_5)
some_digit = X[0]
print("Prediction:", sgd_clf.predict([some_digit]))
print("Decision score:", sgd_clf.decision_function([some_digit]))
3) Performance measures: why accuracy can lie
Why this code matters: If only ~10% of images are digit 5, a naive model can get high accuracy by mostly predicting “not 5.”
What to look for: Compare SGD accuracy against DummyClassifier baseline. If they are close, your model may not be useful.
Common trap: Celebrating high accuracy without checking class distribution or baseline performance.
from sklearn.model_selection import cross_val_score
from sklearn.dummy import DummyClassifier
print("SGD accuracy:",
cross_val_score(sgd_clf, X_train, y_train_5, cv=3, scoring="accuracy"))
dummy_clf = DummyClassifier(strategy="most_frequent")
print("Dummy accuracy:",
cross_val_score(dummy_clf, X_train, y_train_5, cv=3, scoring="accuracy"))
4) Confusion matrix
Why this code matters: The confusion matrix is the source of truth behind most classification metrics.
What to look for:TN: correctly rejected non-5s, FP: wrongly flagged non-5s as 5, FN: missed real 5s, TP: correctly found 5s.
Common trap: Looking only at diagonal totals without considering whether FP or FN is more costly in your use case.
from sklearn.model_selection import cross_val_predict
from sklearn.metrics import confusion_matrix
y_train_pred = cross_val_predict(sgd_clf, X_train, y_train_5, cv=3)
confusion_matrix(y_train_5, y_train_pred)
5) Precision, recall, F1
Why this code matters: These metrics let you optimize for the failure mode that matters most.
What to look for:
High precision means fewer false alarms; high recall means fewer misses; F1 balances both when you need a single score.
Common trap: Maximizing F1 by default. In many domains, one error type is far more expensive than the other.
from sklearn.metrics import precision_score, recall_score, f1_score
precision = precision_score(y_train_5, y_train_pred)
recall = recall_score(y_train_5, y_train_pred)
f1 = f1_score(y_train_5, y_train_pred)
precision, recall, f1
6) Precision/Recall trade-off
Why this code matters: Classifiers output scores, and your threshold turns those scores into decisions.
What to look for: As threshold increases, precision usually rises while recall falls. Pick threshold based on product/business cost.
Common trap: Keeping threshold at default 0 without validating if it matches your required precision or recall target.
from sklearn.metrics import precision_recall_curve
y_scores = cross_val_predict(
sgd_clf, X_train, y_train_5,
cv=3,
method="decision_function"
)
precisions, recalls, thresholds = precision_recall_curve(
y_train_5, y_scores
)
plt.plot(thresholds, precisions[:-1], label="precision")
plt.plot(thresholds, recalls[:-1], label="recall")
plt.legend()
plt.xlabel("threshold")
plt.grid(True)
plt.show()
7) ROC curve
Why this code matters: ROC summarizes ranking performance across all thresholds.
What to look for: Curves closer to top-left and larger AUC indicate better separability than random guessing.
Common trap: Using ROC alone on imbalanced data. Precision-Recall curves are often more informative when positives are rare.
from sklearn.metrics import roc_curve, roc_auc_score
fpr, tpr, roc_thresholds = roc_curve(y_train_5, y_scores)
auc = roc_auc_score(y_train_5, y_scores)
plt.plot(fpr, tpr, label=f"SGD (AUC={auc:.4f})")
plt.plot([0, 1], [0, 1], "--", label="random")
plt.xlabel("FPR")
plt.ylabel("TPR")
plt.legend()
plt.grid(True)
plt.show()
8) Random Forest comparison
Why this code matters: Same task, different model family; this checks whether the issue is data-limited or model-limited.
What to look for: Compare auc (SGD) vs auc_forest. Better AUC suggests better ranking quality over thresholds.
Common trap: Comparing models using different validation setups. Keep CV split and metric consistent for fair comparison.
from sklearn.ensemble import RandomForestClassifier
forest_clf = RandomForestClassifier(random_state=42, n_estimators=200)
y_probas_forest = cross_val_predict(
forest_clf,
X_train,
y_train_5,
cv=3,
method="predict_proba"
)
y_scores_forest = y_probas_forest[:, 1]
auc_forest = roc_auc_score(y_train_5, y_scores_forest)
auc, auc_forest
9) Multiclass classification
Why this code matters: Real tasks often require choosing among many labels, not just yes/no.
What to look for: Baseline multiclass accuracy vs scaled-pipeline accuracy; SGD usually benefits from feature scaling.
Common trap: Forgetting to include preprocessing in the cross-validation pipeline, which causes leakage or inconsistent evaluation.
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
sgd_clf_multi = SGDClassifier(random_state=42)
print("Multiclass accuracy:",
cross_val_score(sgd_clf_multi, X_train, y_train,
cv=3, scoring="accuracy"))
sgd_scaled = make_pipeline(
StandardScaler(),
SGDClassifier(random_state=42, max_iter=100)
)
print("Scaled accuracy:",
cross_val_score(sgd_scaled, X_train, y_train,
cv=3, scoring="accuracy"))
10) Error analysis
Why this code matters: A confusion matrix for multiclass reveals where the model struggles, not just how much.
What to look for: Pairs of digits with high confusion (e.g., similar shapes). Those patterns guide targeted improvements.
Common trap: Stopping at one aggregate score instead of diagnosing specific confusion pairs.
from sklearn.metrics import ConfusionMatrixDisplay
y_train_pred_multi = cross_val_predict(
sgd_scaled, X_train, y_train, cv=3
)
cm = confusion_matrix(y_train, y_train_pred_multi)
ConfusionMatrixDisplay(cm).plot(cmap="Blues")
plt.show()
11) Multilabel & Multioutput
Multilabel
Why this code matters: One sample can have multiple valid labels (not mutually exclusive), which is common in tagging systems.
What to look for: Output has multiple booleans per sample: here, >=7 and odd are predicted together.
Common trap: Treating multilabel as multiclass and forcing only one label per sample.
from sklearn.neighbors import KNeighborsClassifier
y_train_large = (y_train >= 7)
y_train_odd = (y_train % 2 == 1)
y_multilabel = np.c_[y_train_large, y_train_odd]
knn_clf = KNeighborsClassifier()
knn_clf.fit(X_train, y_multilabel)
knn_clf.predict([some_digit])
Multioutput (denoising)
Why this code matters: Multioutput predicts multiple targets at once; denoising predicts a whole clean pixel vector.
What to look for: The predicted digit should preserve structure while removing injected random noise.
Common trap: Assuming all classifiers support large multioutput targets efficiently; memory and latency can grow quickly.
rng = np.random.RandomState(42)
noise = rng.randint(0, 100, (len(X_train), 784))
X_train_mod = X_train + noise
y_train_mod = X_train
knn_denoise = KNeighborsClassifier()
knn_denoise.fit(X_train_mod, y_train_mod)
clean_digit = knn_denoise.predict([X_train_mod[0]])
plot_digit(clean_digit[0])
plt.show()
What we learned
Use this as a practical checklist:
- Start with a simple binary slice to understand errors clearly.
- Always compare against a naive baseline before trusting accuracy.
- Inspect confusion matrix before optimizing any single metric.
- Pick precision/recall target based on business cost of FP vs FN.
- Tune threshold intentionally; default threshold is rarely optimal.
- Compare model families under the same validation protocol.
- For multiclass/multilabel tasks, diagnose per-label confusion, not just one aggregate score.
🔗 Full runnable notebook:
▶ Run this notebook on Google Colab
Inspecting the California Housing Dataset (Before Preprocessing)
Before any preprocessing or modeling, we should first understand the raw dataset: its structure, data types, missing values, and basic statistical properties. This step prevents silent bugs and informs correct preprocessing decisions later.
Load the Dataset
We download and load the California Housing dataset from Géron’s public repository. The dataset is cached locally to avoid repeated downloads.
from pathlib import Path
import pandas as pd
import tarfile
import urllib.request
def load_housing_data():
tarball_path = Path("datasets/housing.tgz")
if not tarball_path.is_file():
Path("datasets").mkdir(parents=True, exist_ok=True)
url = "https://github.com/ageron/data/raw/main/housing.tgz"
urllib.request.urlretrieve(url, tarball_path)
with tarfile.open(tarball_path) as housing_tarball:
housing_tarball.extractall(path="datasets", filter="data")
return pd.read_csv(Path("datasets/housing/housing.csv"))
housing_full = load_housing_data()
housing_full.shape, housing_full.head()
This gives us a first look at:
- dataset size (rows × columns)
- feature names
- rough value ranges
Dataset Structure and Data Types
We inspect column types and missing-value counts.
housing_full.info()
This step answers:
- which features are numeric vs categorical
- whether any columns have missing values
- whether dtypes are suitable for downstream pipelines
Descriptive Statistics (Numeric Features)
We compute summary statistics for numeric columns.
housing_full.describe()
From this, we can quickly spot:
- skewed distributions (mean vs median)
- extreme min/max values (potential outliers)
- scale differences across features
Categorical Feature Distribution
The dataset contains one categorical feature: ocean_proximity.
housing_full["ocean_proximity"].value_counts()
This reveals:
- category balance
- whether rare categories exist
- whether one-hot encoding is appropriate
Missing Value Analysis (Counts)
We check how many values are missing in each column.
housing_full.isna().sum().sort_values(ascending=False).head(20)
This helps decide:
- which features require imputation
- whether any columns might be dropped entirely
Missing Value Analysis (Ratios)
Absolute counts can be misleading, so we also inspect missing-value ratios.
housing_full.isna().mean().sort_values(ascending=False).head(20)
As a rule of thumb:
- features with very high missing ratios deserve scrutiny
- low ratios are usually safe for median or mode imputation
Correlation with Target Variable
We compute correlations for numeric features and focus on the target variable.
num_df = housing_full.select_dtypes(include="number")
corr = num_df.corr(numeric_only=True)
corr["median_house_value"].sort_values(ascending=False)
This provides a quick signal check:
- which features are strongly correlated with the target
- which features may be redundant or weak predictors
Correlation is not causation, but it is a useful early filter.
Summary
In this section we:
- inspected dataset shape and schema
- identified numeric and categorical features
- analyzed missing values
- examined basic statistical properties
- checked correlations with the target variable
With this understanding, we are now ready to design correct and informed preprocessing pipelines without guessing.
🔗 Full runnable notebook:
▶ Run this notebook on Google Colab
End-to-End Regression with scikit-learn (Numeric Features Only)
This chapter demonstrates a complete regression workflow using scikit-learn and the California Housing dataset. All features in this dataset are numeric, which allows us to focus on the core machine learning pipeline without introducing categorical preprocessing.
The goal is not to optimize performance aggressively, but to build a correct, leak-free, and reproducible workflow.
Load the Dataset
We load the California Housing dataset directly from scikit-learn.
It contains 8 numeric features and one numeric target variable, MedHouseVal.
data = fetch_california_housing(as_frame=True)
df = data.frame.copy()
target_col = "MedHouseVal"
df.head()
Train / Test Split
We split the dataset into training and test sets before any preprocessing. The test set is treated as hands-off until the final evaluation to prevent data leakage.
train_df, test_df = train_test_split(
df,
test_size=0.2,
random_state=42
)
train_df.shape, test_df.shape
Separate Features and Target
We separate input features (X) from the target variable (y) for both training and test data.
X_train = train_df.drop(columns=[target_col])
y_train = train_df[target_col].copy()
X_test = test_df.drop(columns=[target_col])
y_test = test_df[target_col].copy()
Numeric Feature Preprocessing
All features in this dataset are numeric. We explicitly list the numeric columns and define a preprocessing pipeline that:
- imputes missing values using the median
- standardizes features using
StandardScaler
num_cols = list(X_train.columns)
num_cols
numeric_pipeline = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
Build the Full Pipeline
We choose a RandomForestRegressor as the model and combine it with the preprocessing pipeline.
Keeping preprocessing and modeling inside a single pipeline ensures consistency across training and evaluation.
model = RandomForestRegressor(
n_estimators=300,
random_state=42,
n_jobs=-1
)
pipe = Pipeline(steps=[
("prep", numeric_pipeline),
("model", model),
])
print(pipe)
Cross-Validation on the Training Set
Before touching the test set, we estimate performance using 5-fold cross-validation on the training data. We use RMSE (Root Mean Squared Error) as the evaluation metric.
scores = cross_val_score(
pipe,
X_train,
y_train,
scoring="neg_root_mean_squared_error",
cv=5
)
rmse = -scores
rmse.mean(), rmse.std()
Final Evaluation on the Test Set
After cross-validation, we fit the pipeline on the full training set and evaluate once on the test set. This provides an unbiased estimate of generalization performance.
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
test_rmse = root_mean_squared_error(y_test, y_pred)
test_rmse
Interpretation
The final test RMSE is approximately 0.50.
Since the target variable is measured in hundreds of thousands of dollars, this corresponds to an average prediction error of roughly $50,000.
This result is realistic for this dataset and confirms that the workflow is functioning correctly.
Summary
In this chapter we:
- split the data before preprocessing to avoid leakage
- built a numeric-only preprocessing pipeline
- combined preprocessing and modeling using
Pipeline - evaluated using cross-validation and a final test set
This structure serves as a clean foundation for future extensions, such as handling categorical features or experimenting with other models.
Reproducibility
- Python ≥ 3.10
- scikit-learn ≥ 1.6.1
- Fixed random seed (
random_state=42)
🔗 Full runnable notebook:
▶ Run this notebook on Google Colab
Linear and Logistic Regression
This post summarizes my takeaways from the first chapter of the Machine Learning Specialization.
It covers the basics of linear regression, gradient descent, logistic regression, and the problem of overfitting. More importantly, I’ll focus on why we use these ideas, what they solve, and how they fit together.
Linear Regression
What it does?
Given historic data, it predicts new values (like house price, car mileage, or sales revenue). Instead of guessing outcomes, linear regression provides a systematic way to estimate them from data.
How to find the best fit LR model in practice?
There are two main challenges:
-
Choosing the right model form – deciding what features (and transformations of features) to include.
-
Finding the best parameters – estimating the weights associated with those features.
The second problem is straightforward. Algorithms like gradient descent can find the optimal parameters. Given a fixed model, this process is deterministic and guarantees the best weights.
The first problem is much harder. No algorithm can directly tell you the “correct” polynomial degree, transformations, or feature interactions. This is a model selection problem, not just an optimization one. It requires exploration and judgment.
Feature engineering is often considered an art because it involves:
- Domain expertise
- Experimentation
- Iterative refinement
- Human judgment
In this course, the focus is primarily on the second challenge, introducing Gradient Descent as a way to optimize parameters.
Gradient Descent, Loss Function, and Cost Function
- Loss function: The error for a single training example (“How wrong am I here?”).
- Cost function: The average error across the whole dataset (“How wrong am I overall?”).
Gradient descent works by repeatedly adjusting parameters to reduce the cost function.
Because gradient descent must loop through potentially millions (or billions) of features — especially in large-scale models like LLMs — computation speed becomes critical. This is why GPU acceleration plays a pivotal role in modern machine learning: it dramatically speeds up these large-scale calculations.
Logistic Regression (Classification)
What it does?
While linear regression predicts continuous values, logistic regression is used for classification tasks — predicting categories such as spam vs. not spam, disease vs. no disease, etc.
Instead of fitting a straight line, logistic regression uses the sigmoid function to squeeze predictions into a range between 0 and 1.
- Output: A probability. Example: 0.9 → very likely spam.
- Decision rule: If probability ≥ 0.5 → predict class 1, otherwise class 0.
Cost Function for Logistic Regression
Squared error (used in linear regression) doesn’t work well for classification because it doesn’t handle probabilities properly.
Instead, logistic regression uses log loss (cross-entropy loss):
- Penalizes confident but wrong predictions much more heavily.
- Rewards probabilities that better reflect reality.
This ensures the model doesn’t just guess classes, but actually learns meaningful probabilities.
Overfitting
When a model memorizes training data instead of learning general patterns. It performs great on training data but poorly on unseen data.
Common fixes:
- Add more data when possible
- Apply regularization (penalize overly complex models).
- Simplify the model structure (loop back to the first challenge mentioned earlier)
Final Thoughts
This chapter laid the groundwork for supervised learning:
- Linear regression for continuous values vs. logistic regression for categories.
- Gradient descent as the universal optimization method.
- Why the right cost function matters.
- How to spot and address overfitting early.
AI & Machine Learning Archive 2026
Older AI and machine learning threads from 2026 land here after they rotate out of the sidebar.
Coding
Rust
Trait
Traits
Types in Rust are powerful and versatile. Traits define shared behavior that multiple types can implement.
Default implementations
Traits in Rust are similar to interfaces in other languages but can also provide default implementations for methods.
Typically, an interface defines method signatures without any implementation. In Rust, however, you can supply default method implementations directly within a trait.
Hover over the code below and click “ “ to execute and see the result.
trait Greet {
fn greet(&self) { // Default implementation
println!("Hello from the default greeting!");
}
}
struct Person;
impl Greet for Person {} // Uses the default implementation
fn main() {
let person = Person;
person.greet(); // Outputs: Hello from the default greeting!
}
We can, of course, override this default behavior like so:
trait Greet {
fn greet(&self) {
println!("Hello from the default greeting!");
}
}
struct Person;
// --snip--
impl Greet for Person {
fn greet(&self) { // Overwrite the default implementation
println!("Hello from Person greeting!");
}
}
fn main() {
let person = Person;
person.greet(); // Outputs: Hello from Person greeting!
}
Traits as Function Parameters and Return Types
Once the Greet trait is defined, it can be used as a type for function parameters and return values:
fn main() {
let person = Person;
do_greet(person);
let returned = return_greet();
returned.greet();
}
fn do_greet(greetable: impl Greet) { // Accepts any type implementing Greet
greetable.greet();
}
fn return_greet() -> impl Greet { // Returns some type implementing Greet
Person
}
// --snip--
struct Person;
impl Greet for Person {}
trait Greet {
fn greet(&self) {
println!("Hello, greeting!");
}
}
When reading more and more Rust code, we see that experienced Rust developers frequently use standard library traits. Rust provides common traits as both guidelines and best practices. This not only helps us learn Rust but also provide great references when programming in other languages.
In the following section, let’s have a look at the out-of-box common traits!
Common Traits
Let’s briefly explore some standard library traits to understand their practical use.
- Debug
- Display
- Default
- Clone
- Copy
- From/Into
- Eq and PartialEq
- Ord and PartialOrd
These traits enable a rich set of tools that work seamlessly across many types. Let’s look at a few examples to illustrate their usefulness.
Debug
When we build a custom struct, like the Point below, we’d often like to
display the content to the users. If we println! as below, it doesn’t work.
Click the “run” button in the code below and see what the compiler tells.
struct Point {
x: i32,
y: i32,
}
fn main() {
let origin = Point { x: 0, y: 0 };
println!("{}", origin); // not work
}
The compiler error implies that the Point needs to implement std::fmt::Display in
order for the line println!("{}", origin) to execute. We will discuss
Display trait in a minute. Now, we’d like to check the more common trait
Debug.
The Debug trait enables us to inspect the content by by allowing types to be
printed using the {:?} formatter in macros like println!.
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let origin = Point { x: 0, y: 0 };
println!("{:?}", origin); // Output: Point { x: 0, y: 0 }
As shown in the 1st line, the easist way to implement Debug trait is to derive
it explicitly with #[derive(Debug)], and then {:?} works now!
Display
Now it’s Display. Unlike Debug, the Display trait is for user-facing output.
Implementing it requires us to define how the type should look when printed.
use std::fmt;
struct Point {
x: i32,
y: i32,
}
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
fn main() {
let p = Point { x: 3, y: 4 };
println!("{}", p); // Output: (3, 4)
}
You might have noticed that there is no #[derive(Display)] here. This is
because Rust’s standard library doesn’t provide such a macro, but there are
external crate like derive_more to get
this functionality.
Speaking of Debug v.s. Display, if the type is meant to be readable by
users, implement Display. If it’s for developers, implement Debug. We can do
both.
Default
The Default trait defines what it means to create a “default” value of a type. It is often used when initializing structures with default configurations.
#[derive(Debug,Default)]
struct Config {
debug_mode: bool,
max_connections: u32,
}
fn main() {
let config = Config::default(); // All fields set to their default values
println!("{:?}", config); // Let's print the content out
}
If you run the code above, the result is Config { debug_mode: false, max_connections: 0 }.
Let’s take a close look at the above code.
We have derived Debug in
order to prinln with {:?}. And we have also derived Default. Rust
allows us to derive Default because both of the two fields (bool and u32)
have implemented Default trait, with values false and 0 respectively.
Be aware that many Rust types do not implement Default. It is
only implemented when it makes sense to define a “reasonable default value”. For
example, std::fs::File. Opening or creating a file requires a path — no
default makes sense.
use std::fs::File;
#[derive(Debug, Default)]
struct Config {
debug_mode: bool,
max_connections: u32,
file: File, // compiler complains here
}
fn main() {
let config = Config::default();
println!("{:?}", config);
}
Clone and Copy
From/Into
Eq and PartialEq
Ord and PartialOrd
Single Project to Workspace
As you build projects in Rust, it’s natural to start simple: a binary (src/main.rs) or a library (src/lib.rs) sitting neatly in a single Cargo project. But as your ideas grow, your project can benefit from a little more structure.
In this chapter, we’ll walk through the journey of evolving a simple Rust project into a workspace. We’ll see why you might want to do it, what changes are involved, and what benefits you gain along the way. If you’re curious about the “next step” in organizing your Rust code, this is for you!
The Starting Point: One Project, One Crate
Let’s imagine you begin with a simple library and binary combined in one project:
my-project/
|- Cargo.toml
|- src/
|- lib.rs
|- main.rs
The Cargo.toml declares both a library and a binary:
[package]
name = "my-project"
version = "0.1.0"
edition = "2021"
[dependencies]
[lib]
path = "src/lib.rs"
[[bin]]
name = "my-project"
path = "src/main.rs"
You don’t even need the [lib] and [[bin]] sections if you don’t overwrite
the default settings in the sample above as the name is identical to the project
name.
This works beautifully for small programs. You can define reusable code in
lib.rs, and build your CLI, server, or app in main.rs, calling into the library.
But what if you want to:
- Add another binary (e.g., a CLI tool and a server)?
- Create internal libraries that aren’t part of the public API?
You surely can still put all logic into the lib crate but how about separating concerns more cleanly across crates? That’s where workspaces shine!
Moving to a Workspace
A Cargo workspace is a way to manage multiple related crates together. Think of it like a “super-project” that coordinates building, testing, and managing dependencies across multiple packages.
Let’s transform our project step-by-step.
Create a Cargo.toml for the workspace
Move the existing Cargo.toml into a new my-project/ sub-folder. Then, create a top-level Cargo.toml:
[workspace]
members = [
"crates/my-project-lib",
"crates/my-project-cli",
]
The members list tells Cargo which packages belong to the workspace.
Split the code into crates
We’ll create two crates inside a new crates/ directory as implied in the above
workspace Cargo.toml.
my-project/
|- Cargo.toml (workspace)
|- crates/
|- my-project-lib/
|- Cargo.toml
|- src/
|- lib.rs
|- my-project-cli/
|- Cargo.toml
|- src/
|- main.rs
my-project-lib will hold the reusable library code, while my-project-cli will be
a binary crate depending on my-project-lib.
Update the crate dependencies
In crates/my-project-cli/Cargo.toml:
[package]
name = "my-project-cli"
version = "0.1.0"
edition = "2024"
[dependencies]
my-project-lib = { path = "../my-project-lib" }
Now your CLI crate can call into the library just like before. Spend some time
to put the code and tests into the corresponding crate. It’s a good brain
excercise, with the help of cargo build --workspace, to define the clear crate
bundary which might not be the case before.
Why Bother?
At first, moving to a workspace might feel like extra overhead. But it brings powerful benefits, even for relatively small projects:
- Clear Separation of Concerns: Each crate focuses on a specific task. Your codebase becomes easier to understand and maintain.
- Faster Builds: Cargo can rebuild only the crates that changed, rather than the entire project.
- Multiple Binaries: You can easily add more binaries (tools, servers, utilities) alongside your main app.
- Internal Libraries: Share code across multiple binaries without publishing it externally.
- Testing in Isolation: You can run cargo test per-crate to get faster, more focused feedback.
- Ready for Growth: When you eventually want to split parts into separate published crates (on crates.io) or keep internal libraries private, you’re already halfway there.
In short, workspaces help your project scale without becoming messy.
A Natural Evolution
You don’t have to start with a workspace when writing your first Rust project. But when your project grows just a little—adding a second binary, needing some internal shared code—workspaces offer a clean and powerful way to stay organized.
The best part? Moving to a workspace is an incremental change. You can migrate a project in stages, and Rust’s tooling (Cargo) makes it smooth.
If you’re curious, give it a try on your next project! You’ll gain both clarity and flexibility. In this small CLI project of mine: jirun, I have transferred it into workspace style to prepare for growing :)!
Cross-Compiling ZeroClaw for Raspberry Pi on NixOS
2026-02-19
I have zeroclaw that I wanted to run on a Raspberry Pi 5 (Ubuntu 24.04 LTS, aarch64). My development machine is a NixOS x86_64 desktop. The goal: compile on the desktop, deploy to the Pi—without touching the Pi’s package manager or installing a Rust toolchain there.
This post documents how I got it working using a Nix devShell, and the several issues that came up along the way.
The Plan: devShell, not a Nix derivation
The first design decision was how to drive the cross-compilation inside Nix. Two options exist:
- Nix derivation/package — let Nix build and manage the output as a store path.
- devShell — enter a shell that has the cross-toolchain on
PATHand runcargo buildmanually.
I chose the devShell approach. zeroclaw is not a Nix-managed package, it lives
at ~/zeroclaw/ as a normal checkout. I just needed the right compiler
environment available when I ran cargo build.
Setting Up the devShell
I added a zeroclaw-cross entry to the devShells output in flake.nix. The
key ingredients:
pkgsCrossinstantiated foraarch64-multiplatformvialib.systems.examples.aarch64-multiplatformrust-overlayoverlays threaded through sorust-bin.stable."1.92.0"(the version pinned in zeroclaw’srust-toolchain.toml) is available- The aarch64 GCC cross-toolchain from
pkgsCross.stdenv.cc opensslandpostgresqlfrompkgsCross.pkgsStaticfor static linking
The relevant env vars exposed in the shell:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER =
"${pkgsCross.stdenv.cc}/bin/aarch64-unknown-linux-gnu-gcc";
CC_aarch64_unknown_linux_gnu =
"${pkgsCross.stdenv.cc}/bin/aarch64-unknown-linux-gnu-gcc";
CXX_aarch64_unknown_linux_gnu =
"${pkgsCross.stdenv.cc}/bin/aarch64-unknown-linux-gnu-g++";
AR_aarch64_unknown_linux_gnu =
"${pkgsCross.stdenv.cc}/bin/aarch64-unknown-linux-gnu-ar";
Because forAllSystems in my flake only covers x86_64-linux and
x86_64-darwin, the shell was added outside that helper using a // merge on
the devShells output:
devShells = forAllSystems (...) // {
"x86_64-linux" = devShells."x86_64-linux" // {
zeroclaw-cross = pkgs.mkShell { ... };
};
};
the full flake.nix is here.
Then entering the shell and building is just:
nix develop .#zeroclaw-cross
cd ~/zeroclaw
cargo build --release --target aarch64-unknown-linux-gnu
Issue 1: C crates ignoring the cross-compiler
The first build attempt failed midway with ARM assembly errors inside blake3
and aws-lc-sys. The error looked roughly like:
error: unknown directive
.arch armv8-a
This is the classic sign that the C compiler used by the cc crate is the
host gcc, not the cross gcc. Setting only
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER is not enough. That variable
tells Cargo which linker to use for the final link step, but crates that compile
C/C++/asm via the cc crate look at a different set of env vars:
CC_aarch64_unknown_linux_gnu
CXX_aarch64_unknown_linux_gnu
AR_aarch64_unknown_linux_gnu
Adding all four variables to the devShell fixed the issue. The build then completed in about 3.5 minutes.
Issue 2: Binary hardcoded to the Nix store interpreter
With the binary built, I copied it to the Pi:
scp ~/zeroclaw/target/aarch64-unknown-linux-gnu/release/zeroclaw \
ubuntu@192.168.1.109:~/.cargo/bin/zeroclaw
Running it on the Pi gave:
bash: /home/ubuntu/.cargo/bin/zeroclaw: cannot execute: required file not found
Checking the ELF interpreter with readelf revealed the problem:
[Requesting program interpreter: /nix/store/ghvpbda663vmf62g22d9y47z55yh43xn-glibc-aarch64-unknown-linux-gnu-2.42-47/lib/ld-linux-aarch64.so.1]
The binary was built against the Nix glibc, so its dynamic linker path
points into /nix/store/—a path that simply doesn’t exist on Ubuntu. The fix is
patchelf, which rewrites the ELF header in-place:
nix shell nixpkgs#patchelf -- patchelf \
--set-interpreter /lib/ld-linux-aarch64.so.1 \
--set-rpath "" \
~/zeroclaw/target/aarch64-unknown-linux-gnu/release/zeroclaw
After patching, readelf -l showed the standard Ubuntu interpreter path, and
the binary ran on the Pi straight away:
$ zeroclaw --version
zeroclaw 0.1.0
Summary
The full flow that worked:
- Add a
zeroclaw-crossdevShell toflake.nixwithpkgsCrossforaarch64-multiplatformand all four cross-compiler env vars set. - Enter the shell and run
cargo build --release --target aarch64-unknown-linux-gnuinside the project directory. - Patch the ELF interpreter with
patchelf --set-interpreter /lib/ld-linux-aarch64.so.1. scpthe patched binary to the Pi.
Two non-obvious gotchas to keep in mind:
- The
cccrate usesCC_<target>/CXX_<target>/AR_<target>, not the Cargo linker variable—you need all of them for crates with C/asm code. - Nix-built binaries embed the Nix store path as the ELF interpreter. Always
patchelfbefore deploying to a non-Nix system.
Books
Thoughts and summaries from books I’ve read. Most notes are written in Chinese and mirrored to my WeChat public account 「是蛮有味」.
我不是那个声音
推荐 ★★★★★(5 / 5) 读于 Kindle 电子书 语言 中文
给它五星。
这本书给了我一个全新的概念,像推开一扇门,门后是一个我从没见过的世界。它还顺手把我过去做过、却一直没底气的一些事,稳稳地扎下了根。
如果你喜欢自我成长或灵修类的书,我想它在你那儿至少值四星。对我,它是不折不扣的五星。
你不是脑海里那个声音
你跟父母大吵了一架。吵的那会儿,有个声音在你脑子里说:日子没法这么过下去了,又累,又糟,一切都糟透了。
可火气一散,几个钟头后,你跟他们聊了几句,才发现其实没那么糟。那个声音又开始说:生活挺好。
这几个小时里,外面的事一件没动。倒是你的情绪,从一头荡到了另一头。
这两头的情绪,其实都是同一个声音说给你听、再替你拍板的。它跳,它动不动就上头,还常常没什么道理。你从出生第一天起,就一直听它调遣。
那个一会儿唱衰、一会儿又打气的家伙,你停下来看过它一眼吗?
这本书(迈克尔·辛格《清醒地活》)其实只讲了一件事,就在前两章:你不是脑海里那个声音。 后面所有章节,都是在为这一句话做论证、做铺垫、做验证。
先得让你认出这个声音。它是谁?
它情绪化,爱猜,而且总往坏处猜。讲件我自己的事。
有一次坐地铁,对面坐下一个中东面孔的陌生人。我脑子里那个声音立刻响了:小心点,这种人不好惹。于是我下意识跟他拉开距离,躲着他的眼神。
坐着坐着,不知怎么就聊上了,几句闲话。结果他外向、健谈,是个特别有意思的人。
就在那一刻,声音对他的态度整个翻了过来。
你看,这个人从头到尾什么都没变。变的,只有我脑子里那个声音。
读到这,我心里咯噔了一下。
因为这是我读过的书里,第一次有人把“真正的我“和“那个声音“切开,当成两样东西。以前读的所有书,默认的都是同一件事:我,就等于我的声音,加上我的情绪,加上我的念头,加上身上一切的感受,全打包成一个“我“。从没有人跟我说,这中间可以有一道缝。
那到底哪个才是我?
书给的答案是:觉知。那个在看、在听、在知道的,才是你。
其余的,都是被你觉知到的东西,只是离你远近不同。念头和声音贴得最近,近到你几乎以为它就是你。情绪稍远一点。外面的世界,那些人、那些事,离你最远。
你在正中心。往外一圈是念头,再一圈是情绪,最外一圈是世界。它们都从你面前经过,没有一个是你。
这个说法很新鲜。而且,它说得通。
就凭这一点,我想往下读。
别的书处理情绪,这本先处理声音
市面上讲情绪的书,几乎都停在情绪这一层。教你怎么消化愤怒,怎么安抚焦虑,怎么跟难过相处。
但情绪,其实是下游。
回到刚才吵架那一幕:先是声音说“日子没法过了“,你才跟着觉得糟。声音在前,情绪在后。是那句话,点着了那团情绪。
再看一眼上面那张图。声音离你最近,情绪比它更远一圈。你去处理情绪,是在跟最外圈较劲,可真正拨动它的那只手,在里圈。
这本书把顺序倒过来。它说,先处理声音。
以前我一直不太明白,冥想到底图什么。坐在那儿,什么都不干,然后呢?说是能静心,可静下来之后要去哪,没人跟我讲清楚。
这本书头一回给了我一个站得住的理由。
冥想的时候,我能清清楚楚看见:那个声音在讲一个又一个故事。而我,学着坐到一旁,看着它、听着它,像在看另一个人。它是它,我是我。
那个声音不是我,我只是在体验它。它能停,做对了,它真能静下来,静到一个念头都没有。它也能吵,吵得像大多数人脑子里那样,一刻不歇。静也好,吵也好,它都只是我此刻觉知到的一样东西,跟窗外的风、手里的凉意没什么两样。一件我正在经历的事,仅此而已。
这件事,就是在练。一遍遍地看,一遍遍地分开,练成肌肉记忆。
等回到真实生活里,声音又开始编故事的时候,这块肌肉记忆就派上用场了。我能更快把它和真正的我分开,而不是一头栽进它的故事里。
一旦陷进去,你拉不出自己
为什么要一直练,练两下不能收工?
因为把你卷走的力道,有强有弱。
拿看电影说。一个好故事,配上画面,配上声音,就足够把你拽进去。灯一暗,你忘了自己坐在影院里,跟着银幕又哭又笑。
那要是再加上气味呢?加上触感呢?故事再好一点,好到你的念头和情绪都跟着它同步起伏?
我想,绝大多数人会彻底陷进去,忘了外面还有个真实世界。
电影散场,灯亮了,你才回来。可在那两个钟头里,真正的我,是丢了的。
这里有个让我后背发凉的地方:一旦陷了进去,那个真正的我,几乎没法靠自己把自己拽出来。
结局只有两种。要么你从一开始就没丢,一直守着中间那个我。要么你就一直丢着,直到电影散场、那股沉浸自己退掉。
中途醒来?很难。所以真功夫不在陷进去之后,在还没陷进去之前。
电影是无害的版本。换个场景,同一套机制就能要命。
想想那些激情杀人的人。跟人吵起来,动了手,那一刻怒到极点,失手把对方打死了。等他从那股情绪里回过神,多半悔得肠子都青了。
可回过神来,已经太晚。真正的我,早在动手之前就丢了,深深陷在那股怒里。等它退潮,人没了,事也成了。
这就是为什么,我们得刻意去练:把真正的我,从声音里、从那股上头的劲里,认出来。
练出来了,被卷下去的时候,你至少还剩一线机会,把自己拉回来。练得再好一点,你在一开始就不接那个声音的话,因为你认得出:它不讲道理,它神经质。
声音想保护你,却把你封住了
书里还讲了一样东西:我们身体里的能量。
作为中国人,这个我不难接受。气,经络,丹田,我们从小听到大。能量在体内流动,这套说法,东方讲了几千年。
书把这股能量,跟那个声音接上了。
声音成天替你判断:这个好,那个坏;这个安全,那个危险。它是好意,它想护着你,帮你躲开会疼的东西。
可你有没有想过,正是这一句句“这个不行“,把你圈住了。
它每划一条“这里危险,别碰“的线,你的世界就小一圈。声音以为自己在保护你,其实是在替你设界。
更麻烦的是,这些界还会堵住那股能量。你一收缩、一防备、一关上,气就不流了,堵在那儿。
书给的方子,反直觉:别去加固这些界,把它们松开。敞开,让能量重新流起来。
让声音当顾问,别让它当老板
换个更顺手的比方:那个声音,像一个大语言模型。
它们像到骨子里。生成式 AI,你只喂它一个开头、一个词,它就顺着往下续,一句、一段、一整篇,停不下来。脑子里那个声音也一样:生活里随便给它一个由头,它立刻接上,自动往下编,一路生成,不用你催,也停不住。
你可以让它出主意,列选项,抛观点。它反应快,见得多,用好了是把好工具。
但你不会让一个大语言模型替你过完这一生。它给的是参考,不是命令。真正拍板的,永远是你。
对那个声音也一样。听它说,但别让它做主。它建议,你决定。你才是坐在最中间、握着方向盘的那个人。
所以对它,你得看得清清楚楚:它好在哪,坏在哪。它能给你速度和选项,也会塞给你偏见、带你上头。这两面都看透了,你才使得动它,而不是被它使唤。
一旦你把方向盘交出去,你就成了乘客,被它载到哪算哪。
剩下的,留给你自己去翻
书里还有很多没细讲的东西。我挑几样点一下,勾起你的兴趣就行,剩下的留给原书。
关于放开。业力不分好坏,正的负的,都得松手。佛家说“放下一切“,说的就是这个。方法朴素得可疑:放松,原谅,大笑。
关于循序渐进。先在小事上练,练顺了再上大一点的事,最后才是真正难的那种。整本书读下来,感觉像在教人戒烟:道理一句话说得清,做起来是场持久战。
关于当下。跟已经发生的事较劲,是跟自己过不去;为还没来的事操心,是白烧能量。你唯一站得住的地方,是此刻。
关于向死而生。想象死亡随时会来。到那一刻,你没有什么更想做却没做的事,也没为谁、为什么跟自己做过妥协。能这样活,才配叫清醒地活。
这些我都只点到为止。真想尝那口味道,去读原书。我真心推荐。
合上书
道理简单到一句话就说完:你不是那个声音。
难的是做到。它要你几乎时时刻刻都在练,练一辈子。
只有当你真的认定,这是这辈子最要紧的一件事,你才肯为它花这么多功夫,一天一天练下去。
《道德经》里有一句,正好说这种事:
上士闻道,勤而行之;中士闻道,若存若亡;下士闻道,大笑之。不笑不足以为道。
上等人听见这个道理,转身就埋头去做。中等人听了,半信半疑,今天记着明天又忘。下等人听了,哈哈大笑,觉得荒唐。老子接着说:正因为会招这种人笑,它才配叫“道“。
不过这里有件幸运的事。真走上去,你会慢慢和别人不一样:别人拼了命想要的,你不再想;别人躲着、抗拒的,你反而全盘接住。你开始朝着跟大多数人相反的方向走。
老子也写过这种孤独:
众人熙熙,如享太牢,如春登台。我独泊兮,其未兆,如婴儿之未孩……俗人昭昭,我独昏昏;俗人察察,我独闷闷……我独异于人,而贵食母。
大伙儿兴高采烈,像赴盛宴,像春日登台看景。我却淡淡的,没什么反应,像个还不会笑的婴儿。别人精明,我糊涂;别人事事算得清清楚楚,我偏闷声不响。我就是跟人不一样,因为我看重的,是那个滋养万物的源头。
看起来像吃亏,像犯傻。可你心里清楚,你贴着的是源头。
那就够了。
我看见的世界:一个移民,和她的北极星
推荐 ★★★½☆(3.5 / 5) 读于 有声书 · 微信读书 语言 中文
先谢 Lisa。她推荐了这本书,我才开始听。
也是因为这本,我才发现微信读书的 AI 听书已经这么顺。更要紧的是,它不挑 IP。喜马拉雅上的有声书,我人在海外,一点开常常就被版权挡在外面。微信读书没有这道墙。
给它 3.5 分,不是书写得不好。是传记我读得太多了,这一本的新鲜感,对我不太够。
但如果你和我一样,对人工智能有兴趣,又刚好喜欢这样一种故事:一个人很早就从中国到了美国,要闯语言关,扛文化的落差,一点点把自己安放进一个全新的社会,那这本书在你那儿,值 4 分。
我为什么点开这本书
李飞飞这个名字,在机器学习圈子里绕不开。
但说来惭愧,我对她的认识一直停在一行字上:ImageNet 的作者。仅此而已。我甚至不知道她是在美国出生的 ABC,还是从中国过去的移民。
这本书替我补上了这份好奇。这也是我点开它的最主要原因。
命运八分,努力两分
有个朋友问过我一个问题:一个人的一生,命运和努力,各占多少?
我的答案是,八分命运,两分努力。
这本书几乎是这句话的活注脚。
李飞飞的父母,在六七十年代的中国,做了一个极其小众的决定:全家搬去美国。到了之后,日子并不好过。家里很穷,父母基本不会讲英文。她就是在这样的环境里长大的。
书里从头到尾,你都能看到父亲和母亲两种截然不同的性格,一点点落在她身上。
她自己当然也努力。美国那种自由、开放、进取的空气,她尽力去吸收。这是她的那两分。但前提始终是,父母替她做了那个小众的选择,搭好了这座舞台。
没有舞台,努力使不上劲。
打动我的,是前半本
说实话,真正打动我的,是这本书的前半部分。
是那个刚到美国、语言不通、想家、家里还很穷的小女孩。她父母一时找不到工作,全家挤在拮据里过日子。
这样的开头,会让很多第一代移民一下子就红了眼圈。因为那也是我们的开头。
我想起自己刚到芬兰的时候。
课堂上的沉默
我们班上十几个人,有墨西哥来的,有孟加拉来的,还有几个国家,我已经记不太清了。上课用英语。
有一天,老师说,班上有三个同学的英语还得再进步一下,因为课上总感觉沟通不太顺,能看出来我们没太听懂。
我就是其中一个。我说出来的句子,别人常常听不懂:有时是口音,有时是表达不够地道。
那种课堂,经常要大家讨论一个话题,抛出自己的看法和建议。轮到我,话卡在嘴边,就是说不出一个像样的见解。
后来我慢慢想明白,卡住我的不只是英语。很多时候,我心里其实也没攒下一个足够成形的观点。语言只是那层看得见的壳。壳底下,是更空的东西。
自己补的那两寸
意识到问题,就一寸一寸去补。
第一寸,口音。我开始有意识地纠正自己的发音,把话说得让人不费力就能听懂。别小看这件事。你说的内容再好,对方听着累,交流就断在半路上了。
第二寸,见识。别人聊起一个话题,我常常连接都接不住,因为压根没听过。于是我逼自己把知识面铺宽一点,什么都去了解一些,好歹在别人抛过来的时候,我能稳稳接住。
如果说命运是父母替李飞飞搭的那座舞台,那这两寸,就是我自己能挣的那两分。
还有芬兰语这道坎
英语勉强能沟通了,第二道坎又立在面前:芬兰语。
这是本地的语言。我又花了不少时间去啃,努力在日常里用起来,点菜、办事、跟邻居搭话。
有一次,我碰上一个歧视外国人的芬兰人。我跟他吵了起来。那一刻我才真切地体会到什么叫书到用时方恨少,一肚子的火,却找不出几个够劲的词把它顶回去。
语言从来不只是沟通的工具。急了,被冒犯了,它还是你自卫的武器。词不够用,你连还嘴都还不利索。
李飞飞只要闯一道语言关。我有两道。
从怕冷清,到爱独处
李飞飞在书里,一直想念故乡:那里的亲人、文化、味道,种种。她花了很长时间,才把自己安顿好。
我也一样。
刚来的头两年,周围冷清,没什么人,很多事都得一个人扛。为了驱散那份冷清,我会去找中国同胞,一起吃吃喝喝、热闹热闹。说白了,凑的不是朋友,是热闹。他们未必和我志同道合,但那份人气,能暂时盖过孤独。
后来,不知不觉就变了。没有哪一个具体的节点,是一点一点渗进来的。
如今我反倒很享受独处。它把时间还给我,让我能安静地想事情、学东西、做自己真正想做的事。当年拼命想躲开的冷清,成了我现在最舍不得的东西。
时间把思乡,熬成了自处的能力。
说到这,我有个不算成熟的想法,或者说,是我自己的一点世界观。
一个年轻人,最好离开父母,离开熟悉的家乡,到一个陌生的地方去闯一闯。
我知道很多父母舍不得。他们想把孩子留在身边,给他一个温暖的、随时有依靠的港湾。这份心意没有错。
但这些年走下来,我越来越信一件事:一个完整的人格,是磨出来的,不是护出来的。你得亲自去撞语言、撞文化、撞那些没人替你挡的难堪,才能一块一块把自己拼全。温室里长不出这个。被保护得太好的人,人格里往往空着一块,因为那一块,本该由他自己在外面摔出来。
远离,不是为了逃开父母。是为了有一天,能作为一个完整的人,站回他们面前。
书的前半本,我照见了自己。翻到后半本,李飞飞换了一副面孔:那个想家的小女孩,开始一头扎进一件要改变整个领域的事。ImageNet。
后半本,像一场创业
翻到后半本,我脑子里冒出来的两个字,是创业。
ImageNet 从无到有的过程,特别像把一家公司从 0 做到 1,再从 1 做到一万。中间的路我就不铺开讲了。只说一点:李飞飞一路上碰到的坎,几乎每一个,在当时看都像迈不过去的。
更难的是,她几乎得不到支持。周围只有极少数人站在她这边,绝大多数人都不看好。
我只举其中一个例子。
井挖好了,就等出水
ImageNet 这个数据库建成之后,还差最关键的一步:一个配得上它的算法,把它的潜力挖出来。
为此,他们办了一场比赛,想把全世界的机器学习团队都吸引过来,比谁能做出那个算法。历经前面九九八十一难,终于走到了最后、也最关键的一刻。井已经挖好了,就等它出水。
可头两年的比赛,始终没有一个团队能拿出那个算法。
这种滋味太难受了。李飞飞和团队一次次挫败,甚至开始怀疑:走到这最后一步,到底还成不成?那是一种悬在悬崖上的感觉,上不去,也不敢往下看。
转机在第三年。神经网络回来了。那一年,ImageNet 彻底爆火。
这感觉,真的和创业一模一样。
你笃定一件事,就拼了命往前走,走在所有人的前头,眼里只有那颗北极星。旁人看不懂,你也没法证明自己是对的。直到有一天,你撞见另一个能和你契合的人,他手里正好握着那块能把拼图补全的碎片。两下一对上,你才敢真正确认:我当初的笃定,是对的。
合上书
李飞飞的一生,几乎就是这本书里的两句话。
前半程,是命运替她搭的台。父母那个小众的选择,把她放到了美国,也放进了贫困、语言和思乡里。后半程,是她自己的笃定。认准 ImageNet,在几乎没人看好的时候,一直走到井里出水的那一天。
命运给你起点,你没得选。能走多远,看你认准了什么,又肯为它熬多久。
书我给 3.5 分。可那个想家的小女孩,最后把一片没人相信的荒地,熬成了整个领域的绿洲。这件事本身,五星都装不下,得给六星。
男孩、鼹鼠、狐狸和马:我把「相伴」的标准定得太窄了
[英] 查理·麦克西 著,汪晗雪 译
这本书薄得令人发指。站在书店里就能翻完。比《小王子》还好读。
《小王子》好歹是个故事。有玫瑰,有星球,有要你去解的谜。这本几乎没有情节。就是四个家伙一路走,一路说些很小的话。
可它告诉我的,比很多厚书都多。
它太空了,所以装得下你
书里的话简单到你会怀疑。「你长大后想成为什么?」「善良。」就这样。一句,剩下大片留白。
你以为没什么。直到你把自己的日子放进去。
它不往你脑子里塞东西。它让万物穿过自己。你穿过它,看见的是跟自己对上号的那一面。
一百个人读,读出一百种。因为它照的从来不是它自己,是各自的人生。你读到的深刻,其实是你自己的倒影。
差得那么远,却这么要好
男孩一直在问,一直在怕。鼹鼠贪吃蛋糕,话最多。狐狸很久不说话,受过伤,把牙齿留给太靠近的人。马什么都懂,却从不抢着说。
四个物种。四种脾气。几乎没有共同点。
可他们就是彼此最好的朋友。
让我想起《老友记》那群人。差得那么远,凑在一起就是一家。不同不是麻烦。不同,就是这段陪伴的底色。
形影相随这件事
有一句一直没散:他们总在一起,肩并肩,谁也不落下。
我二三十岁的时候,特别想要这种朋友。想死死抓住小时候的玩伴。他们说句什么、做件什么,我都看得很重。生怕散了。
现在反而松了。
有机会在一起,当然开心。很久没联系,也不妨害什么。那份关系还在,只是不必天天拿出来证明。
这本书替我发现了一件事:那种朋友没消失。是我以前把「相伴」的标准定得太窄。非要形影相随,才算数。
其实不用。
一本薄薄的书,我翻了两遍,大概一个小时,也告诉了我好多。
这也是相伴。过了那个年纪,照样有。
Trillion Dollar Coach: The Ruler I Didn’t Know I Was Missing
Eric Schmidt, Jonathan Rosenberg, Alan Eagle
I picked this up expecting a leadership playbook. What I got was a question I couldn’t shake.
The question isn’t in the book directly. It’s implied by Bill Campbell’s memorial. Hundreds of people stood up. CEOs, engineers, athletes, assistants. Not to talk about deals he closed or companies he saved. To say: he made me better. He saw something in me I didn’t see yet. He cared about me when it cost him nothing to look away.
That’s a different ruler. Not what you built. Who you grew.
I’ve been thinking about that ruler for weeks.
Three Ways to Outlast Yourself
Somewhere in the middle of the book I started thinking about mortality. Not in a morbid way. In a “what actually persists after you” way.
There’s the biological answer: children. Your genes move forward, passively, without your involvement.
There’s the creative answer: books, code, ideas. You write something and it broadcasts outward. One-directional. The reader receives it but doesn’t change it. It’s frozen.
Then there’s what Bill did. He transmitted something through relationships. Personal, chosen, messy. Every person he coached received something and then remade it in their own way. Passed it on with their own fingerprints on it. Living and mutable, not frozen.
That’s the most fragile of the three. It requires trust. It requires the other person to actively choose to carry it forward. It can be dropped, distorted, ignored.
But it’s also the most alive. Every time it passes, it gets reinterpreted by a real person in a real context. It’s not a copy. It’s a continuation.
Bill’s ruler measures this: not what you produced, but what you set in motion through other people.
Carnegie and the Motive Problem
I’ve read How to Win Friends and Influence People. Most people in consulting have. It taught me to listen, to ask about what people care about, to let people arrive at conclusions rather than pushing them there.
Those moves overlap heavily with what Bill did. Same surface. Completely different engine.
Carnegie’s frame is “winning” and “influencing.” The tools serve a goal: get people to like you, trust you, hear you. For your purposes.
Bill’s frame is cultivation. You help someone get stronger because you want them to get stronger. Not because it serves you. Sometimes it actively costs you: honest feedback that alienates, time spent on someone who’ll never pay it back, credit you could have taken but didn’t.
The distinction only shows up in the hard moments. When there’s nothing to gain from caring, Carnegie’s approach has no reason to continue. Bill’s does.
Reading this made me audit my own motives. Not comfortable. But useful.
Water and Warmth
The book never mentions the Tao Te Ching (道德经). But reading it, my mind went there. The first term that surfaced: 上善若水 (shangshanruoshui). Highest good is like water. Water. Always below. Never forcing. The sage works without taking credit: “功成事遂,百姓皆谓我自然.” The work is done, the people think it happened naturally.
Bill didn’t grab the wheel. He asked questions until you grabbed it yourself. He took the lowest seat while enabling everyone around him to rise. That’s water logic.
But the Tao is cold. “天地不仁,以万物为刍狗.” Heaven and earth are impartial; they treat everything as straw dogs. The sage acts without attachment. Without emotion. Without favorites.
Bill was the opposite of that. He had favorites. He loved people. He got angry on their behalf. He cried at memorials. He checked in on your kids.
The better comparison is Confucian. 己欲立而立人,己欲达而达人. You want to stand, so you help others stand. You want to flourish, so you help others flourish. Warmth is the point, not a side effect.
His hands moved like the Tao. His heart was Confucian.
What I’m Taking Into My Work
The ruler is the biggest shift this book gave me. And I think it’s the foundation of everything else in it. Without it, the individual moves: the free listening, the honest feedback, the coaching questions, they’re just techniques. You can borrow them from Carnegie and use them for entirely different ends. The ruler is what gives them soul. It’s what makes them click together into something coherent. Because if your measure is who got stronger because of you, then of course you listen deeply. Of course you say the hard thing. Of course you ask instead of tell. Every action follows from the ruler.
Carnegie taught me the moves. Bill changed the measure. You can run Carnegie’s playbook your whole career and still be optimizing for the wrong thing: whether people like you, whether they hear you, whether your influence lands. Bill’s ruler doesn’t ask any of that. It asks who got stronger because of you. That’s a harder question. It’s also the only one worth answering.
Others
My Ergonomic Keyboard Journey: From Sculpt to Glove80
2025-09-13
For years I used a regular keyboard until shoulder and hand tension pushed me to try something better. I switched to the Microsoft Sculpt, which served me well for several years. It was a big step up for comfort, but eventually I wanted to explore something even more ergonomic.
Choosing the Next Keyboard
I started comparing modern ergonomic split keyboards:
- Kinesis Advantage360 – classic reputation, but a bit pricey and bulky.
- Voyager – high-quality build, very compact, but too minimal key count for me.
- Moonlander – modern design, highly customizable.
- Glove80 – wireless, lightweight, with a distinctive curved key layout.
After weighing the pros and cons, I chose the Glove80. Looking back, I believe any of these would have been a massive improvement over my Microsoft Sculpt—let alone a standard laptop keyboard.
My Glove80 (top) and retired Sculpt (bottom).
Changing the Layout Too
At the same time, I took on another challenge: moving from QWERTY to the Hands Down layout. My motivation was better efficiency, less finger strain, and more natural hand movement.
There are more traditional alternatives like Dvorak and Colemak, but I chose the more modern and thumb-friendly Hands Down family—specifically the handsdown-promethium variant.
You can find more handsdown info here.
- Our most familiar QWERTY layout:
- My chosen handsdown-promethium layout:
So, not only did I have to adjust to a new keyboard shape, but also to a completely new key layout. Yes, it was brutal, painfully difficult at first.
That said, I’m very happy with handsdown-promethium. Honestly, I think almost any modern layout is a huge step up from QWERTY. This site provides useful statistics on different layouts to back that up. For example, QWERTY’s Total Word Effort is 2070.6 while handsdown-promethium is only 763.5.
Programmability and Practice
One of the Glove80’s best features is its programmability. I could remap keys, add layers, and customize shortcuts directly on the keyboard. These three features make it possible to tailor the keyboard to my workflow instead of forcing myself to adapt to it.
Layers
A layer is like having multiple keyboards in one. Your base layer handles normal typing, but with a single key press you can switch to another layer—for numbers, symbols, navigation, or anything else. On the Glove80, I set up separate layers for symbols, numbers, cursor movement, and even mouse control. With cursor keys and mouse emulation built in, I rarely reach for an actual mouse—especially when using a tiling window manager like Hyperland.
Home Row Mods (HRM)
Another powerful feature is Home Row Mods. With HRM, keys on the home row act as normal letters when tapped, but as modifiers (Ctrl, Alt, Shift, etc.) when held.
In my case, s, n, t, h on left hand and a, e, i, c on righe hand
are my modifiers as shown on the glove80-layouts screenshot above.
This means I don’t need to stretch my fingers awkwardly to reach modifier keys, reducing strain and speeding up key combos.
The Practice Phase
The first few weeks were slow. I had to retrain my muscle memory while learning both a new keyboard shape and a new layout. Daily practice on keybr.com was essential. Gradually, my typing speed and accuracy started to recover.
Integrating Into My Workflow
Once typing felt natural again, the next step was adapting my dotfiles and tools. For me, that especially meant reconfiguring Tmux and Neovim to fit the new layout. Even small adjustments made a big difference in keeping my workflow smooth and efficient.
One challenge of using a non-QWERTY layout is Vim’s navigation keys.
Traditionally, h, j, k, and l sit comfortably on the home row, making
them ideal for movement. On Handsdown-Promethium, however, these keys are no
longer in the same resting positions, so an alternative was needed.
My solution was to rely on the arrow keys in a dedicated cursor layer. On
the Glove80, they map neatly to the same physical positions as hjkl on a
standard keyboard, which makes the transition more natural. At the same time,
Handsdown-Promethium still places hjkl in reasonably accessible spots, so I
can fall back on them if I want.
This hybrid approach has worked well: I get the familiarity of Vim-style navigation without forcing my fingers into awkward positions.
Typing Demo
Here’s a 1.5-minute typing demo ~40 WPM (words per min) recorded on 13-09-2025.
Notes:
- The printed letters on the keycaps are QWERTY, so they don’t match my actual layout.
- Finger and hand movement is very minimal, which shows the ergonomic advantage.
Final Thoughts
Switching from the Sculpt to the Glove80—while also changing layouts—was not easy. The transition took multiple weeks of patience and practice.
But now, three months later, typing feels lighter, smoother, and more sustainable. I no longer feel the same tension in my shoulders and hands.
If you spend hours typing each day, investing in both an ergonomic keyboard and a better layout is worth it. The short-term pain pays off in long-term comfort and efficiency.
Salesforce Developer Training
This training aims to coach general software engineering skills for Salesforce developers so they can deal with large solutions in the long run.
The Issue it solves
Salesforce platform is a sizable business investment. However, many solutions deteriorate after the first several years.
New features take too long to publish, bugs appear here and there. This issue is actively discussed on LinkedIn: video1, video2
It won’t fix the problem by throwing more developers or testers. On the contrary, a small dev team with the right software engineering skills can manage large solutions.
Content
This training aims to coach the essential software engineering skills for your development team.
The training is one or two days, and include subjects like:
- Clean code concept
- Object Oriented mindset
- Separation of concerns concept
- Unit testing practice and tooling
- Refactoring code
- Salesforce modularization
- Salesforce DevOps
After the training the participants will be able to:
- Understand how to structure the code
- Understand and use clean code concept in work
- Create Object Oriented code in Apex
- Know what is unit testing and how it helps code refactoring
- Know cutting-edge Salesforce 2nd gen package and modularization
- Modern DevOps tools in Salesforce
Requirement
This training requires active participation in discussions and frequent small hands-on Apex programming exercises.
This training can be customized according the your dev team skill level. Follow up and feedback sessions after the training are also highly recommended
To book the training, contact me at tdxiaoxi2@gmail.com.
Thanks for your time!