Skip to content
Open
70 changes: 70 additions & 0 deletions dag/examples/blackjack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Blackjack: a small Dag model

This example is a guided tour of Dag’s four-view loop. It models a small
Blackjack domain; it is not gambling advice.

## 1. Model the facts

The model is split by responsibility:

- `cards.dag` defines `Card`, `Rank`, `Suit`, and `standard_deck`.
- `hand.dag` derives a hand total, softness, bust status, and Blackjack status.
- `round.dag` is the pure round state machine. Invalid transitions are explicit
`RoundRefused` values rather than exceptions or default results.
- `shuffle.dag` turns a recorded `ShuffleSeed` into a deterministic `Shoe`.
- `simulation.dag` runs seeded rounds, aggregates completed observations, and
preserves any engine refusal with its replay seed and round index.

## 2. Run a pure example

A round has no hidden randomness. The same ordered shoe and rules always
produce the same result.

Randomness is kept at the boundary:

```text
Urandom.ReadBytes -> Base64 decode -> ShuffleSeed -> shuffle -> Shoe
```

Once a seed is recorded, a shuffle and every simulated round can be replayed.
Each simulated round uses a fresh shuffled 52-card shoe.

## 3. Inspect test evidence

Witness tests live under `dag/test/claim/examples/`.

They cover hand evaluation, round transitions, deterministic shuffle behavior,
simulation reconciliation, and paired strategies running over the same seeded
shoes. The simulation witness also supplies a deliberately short second shoe
and verifies that the run stops with `SimulationRefused` at the correct index,
seed, and `ShoeExhausted` cause.

The only entropy-dependent test is the wet shuffle-boundary test. It checks
that real decoded entropy produces a 52-card permutation, without asserting a
particular card order.

## 4. Inspect emitted Rust

Dag is the source program. Rust is a generated projection and must not be
edited by hand.

```bash
OUT=$(mktemp -d)

./target/release/gunbc compile \
--source-root dag \
--entry dag/examples/blackjack/simulation.dag \
--output-dir "$OUT" \
--target rust

cargo check --manifest-path "$OUT/Cargo.toml"
```

The model’s key summary invariant is:

```text
player_wins + dealer_wins + pushes == rounds
```

The summary reports observed counts. It does not store a `win_rate` or claim
that one strategy is universally better than another from a finite sample.
51 changes: 50 additions & 1 deletion dag/examples/blackjack/cards.dag
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,53 @@ module examples.blackjack.cards
// section 4.1 (authority gunbc.plans.blackjack_onboarding) and is written by the contributor
// working that brief. This anchor is the one declaration given, so the file parses on day one
// and shows the shape of a closed set of alternatives.
type Suit = Clubs | Diamonds | Hearts | Spades

type Suit
= Clubs
| Diamonds
| Hearts
| Spades

type Rank
= Ace
| Two
| Three
| Four
| Five
| Six
| Seven
| Eight
| Nine
| Ten
| Jack
| Queen
| King

type Card {
rank: Rank
suit: Suit
}

fn rank_pips(r: Rank) -> Int {
match r {
Ace => 11
Two => 2
Three => 3
Four => 4
Five => 5
Six => 6
Seven => 7
Eight => 8
Nine => 9
Ten => 10
Jack => 10
Queen => 10
King => 10
}
}

fn is_ace(c: Card) -> Bool {
c.rank == Ace
}

data standard_deck: List<Card> = [Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King] |> flat_map(r => [Clubs, Diamonds, Hearts, Spades] |> map(s => Card { rank: r, suit: s}))
48 changes: 47 additions & 1 deletion dag/examples/blackjack/hand.dag
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,50 @@ module examples.blackjack.hand
// section 4.2 (authority gunbc.plans.blackjack_onboarding). The anchor is the derived value
// a hand evaluates to; the raw total and the soft flag are distinct facts, which is the
// first modeling lesson of that section.
type HandValue { total: Int, soft: Bool }

import examples.blackjack.cards { Card, Rank, Ace, rank_pips, is_ace }

type Hand {
cards: List<Card>
}

type HandValue {
total: Int
soft: Bool
}

fn soften(total: Int, aces: Int) -> Int {
if total > 21 && aces > 0 {
soften(total: total - 10,aces: aces - 1)
} else {
total
}
}

fn hard_total(hand: Hand) -> Int {
fold(hand.cards, init: 0, f: (acc, c) => acc + rank_pips(r: c.rank))
}

fn hand_value(hand: Hand) -> HandValue {
let raw_total = hard_total(hand: hand)
let ace_count = length(hand.cards |> filter(c => is_ace(c: c)))
let total = soften(total: raw_total, aces: ace_count)
let all_aces_low_total = raw_total - ace_count * 10

HandValue {
total: total,
soft: ace_count > 0 && all_aces_low_total + 10 <= 21
}
}

fn is_bust(v: HandValue) -> Bool {
v.total > 21
}

fn is_blackjack(hand: Hand) -> Bool {
(length(hand.cards) == 2) && (hand_value(hand: hand).total == 21)
}

fn add_card(hand: Hand, card: Card) -> Hand {
Hand { cards: concat(hand.cards, [card]) }
}
207 changes: 207 additions & 0 deletions dag/examples/blackjack/round.dag
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,211 @@ module examples.blackjack.round
// gunbc.plans.blackjack_onboarding). The variants are spelled PlayerHits / PlayerStands
// rather than Hit / Stand because the corpus shares one name census and Hit is already a
// variant of std.cache_interface; that section explains the naming rule.

import examples.blackjack.cards { Card }
import examples.blackjack.hand { Hand, HandValue, hand_value, is_bust, is_blackjack, add_card }

type PlayerAction = PlayerHits | PlayerStands

type BlackjackRules { dealer_hits_soft_17: Bool }

type Shoe { cards: List<Card> }

type RoundState
= PlayerTurn { player: Hand, dealer: Hand, shoe: Shoe }
| DealerTurn { player: Hand, dealer: Hand, shoe: Shoe }
| RoundComplete { player: Hand, dealer: Hand, outcome: RoundOutcome }

type RoundOutcome = PlayerWon | DealerWon | Pushed

type RoundRefusal
= ShoeExhausted
| ActionAfterRoundComplete
| DealerActedOutOfTurn
| PlayerActedOutOfTurn

type RoundStep
= RoundStepped { state: RoundState }
| RoundRefused { cause: RoundRefusal }

fn deal(shoe: Shoe) -> RoundStep {
match shoe.cards.first() {
Absent => RoundRefused { cause: ShoeExhausted }
Present { value: player_first } => match shoe.cards.skip(n: 1).first() {
Absent => RoundRefused { cause: ShoeExhausted }
Present { value: dealer_first } => match shoe.cards.skip(n: 2).first() {
Absent => RoundRefused { cause: ShoeExhausted }
Present { value: player_second } => match shoe.cards.skip(n: 3).first() {
Absent => RoundRefused { cause: ShoeExhausted }
Present { value: dealer_second } => {
let player = Hand {
cards: [player_first, player_second],
}
let dealer = Hand {
cards: [dealer_first, dealer_second],
}
let remaining_shoe = Shoe {
cards: shoe.cards.skip(n: 4),
}

if is_blackjack(hand: player) && is_blackjack(hand: dealer) {
RoundStepped {
state: RoundComplete {
player: player,
dealer: dealer,
outcome: Pushed,
},
}
} else if is_blackjack(hand: player) {
RoundStepped {
state: RoundComplete {
player: player,
dealer: dealer,
outcome: PlayerWon,
},
}
} else if is_blackjack(hand: dealer) {
RoundStepped {
state: RoundComplete {
player: player,
dealer: dealer,
outcome: DealerWon,
},
}
} else {
RoundStepped {
state: PlayerTurn {
player: player,
dealer: dealer,
shoe: remaining_shoe,
},
}
}
}
}
}
}
}
}


fn apply_player_action(state: RoundState, action: PlayerAction) -> RoundStep {
match state {
RoundComplete { player: _, dealer: _, outcome: _ } =>
RoundRefused { cause: ActionAfterRoundComplete }

DealerTurn { player: _, dealer: _, shoe: _ } =>
RoundRefused { cause: PlayerActedOutOfTurn }

PlayerTurn { player: player, dealer: dealer, shoe: shoe } => match action {
PlayerStands => RoundStepped {
state: DealerTurn {
player: player,
dealer: dealer,
shoe: shoe,
},
}

PlayerHits => match shoe.cards.first() {
Absent => RoundRefused { cause: ShoeExhausted }
Present { value: next_card } => {
let new_player = add_card(hand: player, card: next_card)
let remaining_shoe = Shoe {
cards: shoe.cards.skip(n: 1),
}

if is_bust(v: hand_value(hand: new_player)) {
RoundStepped {
state: RoundComplete {
player: new_player,
dealer: dealer,
outcome: DealerWon,
},
}
} else {
RoundStepped {
state: PlayerTurn {
player: new_player,
dealer: dealer,
shoe: remaining_shoe,
},
}
}
}
}
}
}
}

fn play_dealer_turn(state: RoundState, rules: BlackjackRules) -> RoundStep {
match state {
RoundComplete { player: _, dealer: _, outcome: _ } =>
RoundRefused { cause: ActionAfterRoundComplete }

PlayerTurn { player: _, dealer: _, shoe: _ } =>
RoundRefused { cause: DealerActedOutOfTurn }

DealerTurn { player: player, dealer: dealer, shoe: shoe } => {
let dealer_value = hand_value(hand: dealer)
let should_hit =
dealer_value.total < 17
|| (rules.dealer_hits_soft_17 && dealer_value.total == 17 && dealer_value.soft)

if should_hit {
match shoe.cards.first() {
Absent => RoundRefused { cause: ShoeExhausted }
Present { value: next_card } => {
let new_dealer = add_card(hand: dealer, card: next_card)
let remaining_shoe = Shoe {
cards: shoe.cards.skip(n: 1),
}

if is_bust(v: hand_value(hand: new_dealer)) {
RoundStepped {
state: RoundComplete {
player: player,
dealer: new_dealer,
outcome: PlayerWon,
},
}
} else {
play_dealer_turn(
state: DealerTurn {
player: player,
dealer: new_dealer,
shoe: remaining_shoe,
},
rules: rules,
)
}
}
}
} else {
RoundStepped {
state: RoundComplete {
player: player,
dealer: dealer,
outcome: settle(
player: hand_value(hand: player),
dealer: dealer_value,
),
},
}
}
}
}
}

fn settle(player: HandValue, dealer: HandValue) -> RoundOutcome {
if player.total > 21 {
DealerWon
} else if dealer.total > 21 {
PlayerWon
} else if player.total > dealer.total {
PlayerWon
} else if player.total < dealer.total {
DealerWon
} else {
Pushed
}
}
Loading
Loading