Chapter 02 of 18

A Prompt Is Not Yet a Program

Explain this chapter with AI

Copy this prompt into ChatGPT, Claude, Gemini, a local model, or another AI.

Apply this chapter with AI

Copy this prompt into ChatGPT, Claude, Gemini, a local model, or another AI.

A Prompt Is Not Yet a Program

In chapter 1 we built a reasonable handwritten prompt and found the real problem: one prompt string was carrying the task semantics, role and behavioral framing, output request, and model assumptions at the same time.

The next move is not to make a more elaborate prompt. It is to decide what properties an LM component needs before we should reasonably call it a program.

A prompt is text:

"Rewrite this sentence..."

A program has a boundary:

inputs
  โ†“
declared behavior
  โ†“
execution strategy
  โ†“
structured outputs

The boundary matters because it lets us change one part without pretending we changed everything.


1. What a program promises

A normal Python function has a weak but recognizable contract:

def slugify(title: str) -> str:
    ...

The signature says little about the full behavior, but it gives other code something stable to call. It names inputs and outputs. It creates a place for tests. It lets us replace the implementation.

An LM component becomes easier to engineer when these concerns are explicit:

Property Why it matters
Named inputs Callers should not assemble task inputs as ad hoc prose
Declared behavior The semantic job should be visible outside prompt wording
Execution strategy We should know how the declared task is being attempted
Structured outputs Downstream code needs stable fields when the task has structure
Inspectable structure We should be able to identify the program’s components rather than infer them from one prompt string

DSPy gives us handles for these concerns through signatures and modules.

A signature declares the task interface: what fields come in, what fields come out, and what semantic job connects them.

A module controls how that task is attempted. The same signature can be used with direct prediction, explicit reasoning, tool use, or a custom composition of several LM calls and ordinary Python.

That separation is the first important idea.

Signature: what behavior is requested?
Module:    how is that behavior attempted?

2. A small program we can grow

We will keep the running example deliberately small. The program receives one sentence, an editorial goal, and nearby context. It returns one candidate rewrite with a rationale and confidence.

import dspy

class ImproveSentence(dspy.Signature):
    """Improve one sentence while preserving meaning, entities, and voice."""

    sentence: str = dspy.InputField(desc="The exact sentence to improve")
    editorial_goal: str = dspy.InputField(
        desc="The local reason this sentence is being edited"
    )
    local_context: str = dspy.InputField(
        desc="Nearby prose needed to preserve continuity and voice"
    )

    rewritten_text: str = dspy.OutputField(
        desc="A replacement sentence, not a paragraph"
    )
    rationale: str = dspy.OutputField(desc="Short explanation of the edit")
    confidence: float = dspy.OutputField(desc="Confidence from 0.0 to 1.0")

class SentenceImprover(dspy.Module):
    def __init__(self) -> None:
        super().__init__()
        self.rewrite = dspy.Predict(ImproveSentence)

    def forward(
        self, sentence: str, editorial_goal: str, local_context: str = ""
    ) -> dspy.Prediction:
        return self.rewrite(
            sentence=sentence,
            editorial_goal=editorial_goal,
            local_context=local_context,
        )

Assuming an LM is configured, execution is just a call:

program = SentenceImprover()

result = program(
    sentence="She opened the door and then she laughed.",
    editorial_goal="Sharpen rhythm without changing the event.",
    local_context="The scene is tense but not comic.",
)

print(result.rewritten_text)
print(result.rationale)
print(result.confidence)

Current DSPy allows both string signatures and class-based signatures. The class form is better here because the field names and descriptions are part of the visible source, and later chapters will add typed outputs, metrics, examples, and optimization.


3. What changed from the prompt version

The handwritten version had one main artifact:

prompt string

The program version has several:

That extra structure is not ceremony. It gives us handles.

If the downstream editor needs risk in addition to confidence, we change the contract. If the model should reason before answering, we can change the execution policy in the module. If a local model cannot reliably produce floats, we can adjust validation or output design. If we later compile the program using examples, the optimizer has a program to compile rather than a blob of prompt text.

This is how Writer’s DSPy sentence-improvement layer is organized at the product boundary. It records candidate DTOs with candidate_text, provider, model, program, program_revision, prompt_hash, response_hash, evidence_packet_hash, used_real_llm, and fallback_state. That is not just bookkeeping. It reflects a design where generated text is evidence attached to a program run, not an anonymous string.

CoCoder pushes the same distinction further. Its EngineeringProgram architecture explicitly says DSPy is one possible optimizer/runtime adapter, not the architecture itself. The domain object is the program; DSPy may propose a candidate version.


4. Declared behavior is not implementation

A subtle mistake is to put too much implementation into the signature:

class BadImproveSentence(dspy.Signature):
    """Think step by step, compare three alternatives, choose the best one,
    and rewrite the sentence using a terse Hemingway-like style."""

    sentence: str = dspy.InputField()
    rewritten_text: str = dspy.OutputField()

That docstring mixes three different kinds of statement:


5. Outputs are part of the program

The output fields are not decoration. They determine what the rest of the system can do.

For this editorial task, one undifferentiated field is too coarse:

Writer’s contextual preference judge uses this style. It asks for a decision, confidence, reason_codes_json, primary_reason, and explanation. It then parses and validates those fields, applies deterministic hard policies, and records the observation. The LM output enters a larger software system with explicit status and provenance.

That is what a program boundary buys us.


What Usually Goes Wrong

Symptom Likely cause How to diagnose it What to change
The signature reads like a long prompt Task contract and tactic are fused Highlight verbs that describe process rather than required behavior Move process into a module
Downstream code uses regexes on answer Outputs are too coarse Search for parsers and string matching after LM calls Split outputs into fields
Every caller passes slightly different context text Inputs are not stable Compare call sites Name context fields explicitly
Swapping modules requires changing callers Tactic-specific details leaked into the task interface Look for fields named after reasoning steps, prompts, or models Keep signature fields semantic and move tactics behind the module boundary

Conclusion

We gained a program-shaped boundary around a language-model behavior. The prompt is still there somewhere, but it is no longer the thing the rest of the software has to understand.

We removed the assumption that one prompt string should simultaneously carry the task contract, execution tactic, output interface, and version identity.

What remains unsolved is the interface itself. We have a signature, but we have not yet studied how to design a good one. A weak contract can make every later stage worse.

So the next chapter asks:

What exactly is the LM being asked to consume and produce?