# speq — the speqification is the code

**Docstrings as code.**

```python
from speq import ai

@ai
def int_to_english(n: int) -> str:
    """
    Convert an integer to English words. Supports -999999 to 999999.
    Lowercase, words separated by single spaces.
    Examples: 0 -> "zero", 42 -> "forty two", -15 -> "negative fifteen",
    11 -> "eleven", 100 -> "one hundred".
    """
    ...

print(int_to_english(999999))
# "nine hundred ninety nine thousand nine hundred ninety nine"
```

Write a function signature, describe what it should do in the docstring, leave the body as `...`. The implementation is synthesized by an LLM and cached to disk. You never write — or read — the generated code.

## How it works

1. Decorate a function with `@ai`
2. Write a docstring describing the function's behavior
3. Leave the body as `...`
4. On first call, `speq` synthesizes an implementation via LLM
5. The implementation is cached — subsequent calls don't hit the LLM

## Install

```bash
git clone https://github.com/c-shanty/speq.git
cd speq
python3 -m venv .venv
source .venv/bin/activate
pip install ".[dev]"
```

You'll need an Anthropic API key:

```bash
export ANTHROPIC_API_KEY=your-key-here
```

To use a specific model (defaults to Claude Sonnet):

```bash
export SPEQ_MODEL=claude-sonnet-4-20250514
```

Or per-function:

```python
@ai(model="claude-sonnet-4-20250514")
def my_function(x: int) -> int:
    """Double the input."""
    ...
```

## Why not just use Claude Code / Copilot / ChatGPT?

You can ask any AI to write a function. The difference is what happens next.

With Claude Code, the AI writes an implementation and you paste it into your codebase. Now you have code that you didn't write and a conversation that you'll never find again. When the requirements change, you go back to the AI, re-explain the context, and paste again. The code and the intent are in two different places, and they drift immediately.

With `@ai`, the docstring **is** the source code. The intent and the implementation can't drift because one generates the other. Change the docstring, and the implementation regenerates. Upgrade to a better model, delete the cache, and every function in your project gets better automatically. The specs you write today appreciate in value over time.

## Examples

Every function below was synthesized correctly on the first try.

### Functions where the spec is simple but the implementation is not

```python
@ai
def validate_brackets(s: str) -> tuple[bool, int]:
    """
    Check if a string has balanced brackets: (), [], {}.
    Non-bracket characters are ignored.
    If balanced, return (True, -1).
    If unbalanced, return (False, position) where position is the
    index of the first problematic bracket.
    An unmatched closing bracket is problematic at its own position.
    An unclosed opening bracket is problematic at its position.
    """
    ...

validate_brackets("({[]})")  # (True, -1)
validate_brackets("({[}])")  # (False, 3)
validate_brackets("(()")     # (False, 0)
```

```python
@ai
def merge_overlapping_ranges(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
    """
    Merge a list of potentially overlapping integer ranges into
    non-overlapping ranges. Each range is (start inclusive, end exclusive).
    Adjacent ranges that touch are also merged. Input may not be sorted.
    Output must be sorted by start value.

    Examples:
    [(0,5), (3,8), (10,15)] -> [(0,8), (10,15)]
    [(0,5), (5,10)] -> [(0,10)]
    """
    ...

merge_overlapping_ranges([(0, 5), (3, 8), (10, 15)])  # [(0, 8), (10, 15)]
```

```python
@ai
def time_ago(seconds: int) -> str:
    """
    Convert a number of seconds into a human-readable relative time string.
    Examples: 30 -> "just now", 90 -> "1 minute ago", 3600 -> "1 hour ago",
    7200 -> "2 hours ago", 86400 -> "yesterday", 172800 -> "2 days ago",
    2592000 -> "1 month ago", 31536000 -> "1 year ago".
    Use singular when count is 1, plural otherwise.
    seconds is always non-negative.
    """
    ...

time_ago(90)      # "1 minute ago"
time_ago(7200)    # "2 hours ago"
time_ago(86400)   # "yesterday"
```

```python
@ai
def rgb_to_hsl(r: int, g: int, b: int) -> tuple[int, int, int]:
    """
    Convert RGB color values (each 0-255) to HSL.
    Returns (hue 0-359, saturation 0-100, lightness 0-100).
    If saturation is 0 (achromatic), hue should be 0.
    """
    ...

rgb_to_hsl(255, 0, 0)  # (0, 100, 50)
```

### Gradually adopt in existing code

`@ai` works alongside regular Python. Adopt it one function at a time — no rewrite required:

```python
from speq import ai
import db

# existing code — untouched
def get_user(user_id: str) -> User:
    return db.query("SELECT * FROM users WHERE id = %s", user_id)

# new function — let speq handle the fiddly logic
@ai
def format_user_display_name(first: str, last: str, nickname: str | None,
                              prefer_nickname: bool) -> str:
    """
    Format a user's display name. If prefer_nickname is True and nickname
    is not None or empty, return the nickname. Otherwise return
    "First Last". If first or last is empty, omit it (don't leave
    leading/trailing spaces). If all are empty, return "Anonymous".
    """
    ...

# existing code — calls the @ai function normally
def render_profile(user_id: str):
    user = get_user(user_id)
    name = format_user_display_name(user.first, user.last, user.nickname, True)
    return f"<h1>{name}</h1>"
```

## When to use this

`@ai` shines when the **spec is simpler than the implementation**:

- **Tricky string formatting** — number-to-words, relative time ("2 hours ago"), display name logic
- **Algorithmic utilities** — range merging, bracket validation, deduplication
- **Conversion functions** — color spaces, unit conversions, coordinate transforms
- **Business rule calculations** — prorated refunds, tiered pricing, tax rounding
- **Parsing** — phone numbers, addresses, log lines, CSV edge cases

## When NOT to use this

- **Trivial functions** — `return min(a, b)` doesn't need `@ai`
- **Performance-critical hot paths** — you need to control the exact implementation
- **Complex stateful logic** — database transactions, API call chains, anything where execution order and side effects matter (future roadmap)

## CLI

Pre-synthesize functions without running your program:

```bash
speq synthesize myfile.py            # synthesize all @ai functions
speq synthesize myfile.py --force    # re-synthesize even if cached
```

Run auto-generated tests (derived from the spec, not the implementation):

```bash
speq test myfile.py                  # run all generated tests
speq test myfile.py --func time_ago  # test one function
```

See what changed after editing a docstring:

```bash
speq diff myfile.py                  # diff old vs new implementation
```

Manage the cache:

```bash
speq cache list myfile.py            # show cached functions with scores
speq cache clear myfile.py           # clear all cached implementations
speq cache clear myfile.py --func time_ago  # clear one function
```

## Confidence scores

Every synthesis is scored for spec clarity and implementation confidence. Enable debug logging to see them:

```python
import logging
logging.basicConfig(level=logging.DEBUG)

from speq import ai

@ai
def my_function(x: int) -> int:
    """Double the input."""
    ...

my_function(5)
```

```
DEBUG:speq: [my_function] spec clarity:      0.95
DEBUG:speq: [my_function] impl confidence:    0.98
```

For vague specs, you'll get warnings and actionable feedback:

```
DEBUG:speq: [process_data] spec clarity:      0.42
DEBUG:speq: [process_data] impl confidence:    0.51
DEBUG:speq: [process_data] assumptions made:
DEBUG:speq: [process_data]   - input list contains only positive numbers
DEBUG:speq: [process_data]   - empty list should return 0
DEBUG:speq: [process_data] ambiguities found:
DEBUG:speq: [process_data]   - "process" could mean filter, transform, or aggregate
WARNING:speq: [process_data] low spec clarity (0.42) — consider adding more detail
```

Scores are also available programmatically:

```python
scores = my_function.get_scores()
print(scores.spec_clarity)        # 0.95
print(scores.assumptions)         # []
print(scores.missing_edge_cases)  # ["behavior for negative input not specified"]
```

## Caching

Synthesized implementations are cached in `.speq_cache/` next to your source file. The cache key is a hash of the function name, signature, and docstring. Change the docstring and the implementation re-synthesizes on next call.

Delete `.speq_cache/` to force re-synthesis of everything.

## Writing good specs

The better your docstring, the better the implementation.

- **Include examples.** `"Examples: 30 -> 'just now', 90 -> '1 minute ago'"` anchors the implementation.
- **Be specific about edge cases.** "If all are empty, return 'Anonymous'" prevents guessing.
- **State constraints explicitly.** "Use singular when count is 1, plural otherwise" prevents subtle bugs.
- **Describe what, not how.** "Output must be sorted by start value" not "sort the list first then iterate."

## What's next

- Interactive spec review — accept, reject, or clarify the LLM's assumptions
- Module-level context — shared conventions across functions

## License

MIT
