Graph Algorithms · Large-Scale Data · Deep Learning · LLM APIs
GraphQL · gRPC · Property Testing · System Design · Interviews
Unit VI is a synthesis-extension unit: it consolidates Units I–V through integrated systems while introducing the advanced topics that appear in industry and placement interviews. Read §I–II for DSA mastery, §III–V for data/AI relevance, §VI–IX for modern software engineering, and §XI for interview preparation. Every example is tested on Python 3.11+.
A graph \(G = (V, E)\) is a set of vertices \(V\) and edges \(E\) connecting them. Edges may be directed/undirected and weighted/unweighted.
| Representation | Space | Edge check | Iterate neighbours |
|---|---|---|---|
| Adjacency list | \(O(V + E)\) | \(O(\deg)\) | \(O(\deg)\) |
| Adjacency matrix | \(O(V^{2})\) | \(O(1)\) | \(O(V)\) |
| Edge list | \(O(E)\) | \(O(E)\) | \(O(E)\) |
# Adjacency list (most common in Python)
graph = {
"A": ["B", "C"],
"B": ["A", "D", "E"],
"C": ["A", "F"],
"D": ["B"],
"E": ["B", "F"],
"F": ["C", "E"],
}
# Weighted graph
weighted = {
"A": [("B", 4), ("C", 2)],
"B": [("A", 4), ("C", 5), ("D", 10)],
"C": [("A", 2), ("B", 5), ("D", 3)],
"D": [("B", 10), ("C", 3)],
}
Explores level by level using a queue. Finds shortest paths in unweighted graphs.
Time \(O(V + E)\) • Space \(O(V)\) • Uses a FIFO queue
from collections import deque
def bfs(graph, start):
visited = {start}
parent = {start: None}
order = []
q = deque([start])
while q:
node = q.popleft()
order.append(node)
for neighbour in graph[node]:
if neighbour not in visited:
visited.add(neighbour)
parent[neighbour] = node
q.append(neighbour)
return order, parent
def shortest_path(parent, target):
path = []
while target is not None:
path.append(target)
target = parent[target]
return path[::-1]
order, parent = bfs(graph, "A")
print("BFS order :", order) # ['A', 'B', 'C', 'D', 'E', 'F']
print("A → F path:", shortest_path(parent, "F")) # ['A', 'C', 'F']
Explores as deep as possible before backtracking. Uses recursion or an explicit stack.
def dfs_recursive(graph, node, visited=None):
if visited is None:
visited = set()
visited.add(node)
print(node, end=" ")
for neighbour in graph[node]:
if neighbour not in visited:
dfs_recursive(graph, neighbour, visited)
return visited
def dfs_iterative(graph, start):
visited = set()
stack = [start]
order = []
while stack:
node = stack.pop()
if node not in visited:
visited.add(node)
order.append(node)
stack.extend(reversed(graph[node]))
return order
print("Recursive:", end=" ")
dfs_recursive(graph, "A") # A B D E F C
print()
print("Iterative:", dfs_iterative(graph, "A")) # ['A', 'B', 'D', 'E', 'F', 'C']
def has_cycle_undirected(graph):
visited = set()
def dfs(node, parent):
visited.add(node)
for neighbour in graph[node]:
if neighbour not in visited:
if dfs(neighbour, node):
return True
elif neighbour != parent:
return True
return False
for node in graph:
if node not in visited:
if dfs(node, None):
return True
return False
def has_cycle_directed(graph):
WHITE, GRAY, BLACK = 0, 1, 2
color = {node: WHITE for node in graph}
def dfs(node):
color[node] = GRAY
for neighbour in graph[node]:
if color[neighbour] == GRAY:
return True # back edge → cycle
if color[neighbour] == WHITE and dfs(neighbour):
return True
color[node] = BLACK
return False
return any(color[n] == WHITE and dfs(n) for n in graph)
Linear ordering of vertices such that for every edge \(u \to v\), \(u\) comes before \(v\). Only defined on DAGs (directed acyclic graphs).
from collections import deque
def topo_sort(graph):
indegree = {node: 0 for node in graph}
for node in graph:
for neighbour in graph[node]:
indegree[neighbour] += 1
q = deque([n for n, d in indegree.items() if d == 0])
order = []
while q:
node = q.popleft()
order.append(node)
for neighbour in graph[node]:
indegree[neighbour] -= 1
if indegree[neighbour] == 0:
q.append(neighbour)
if len(order) != len(graph):
raise ValueError("Graph has a cycle — no topological order")
return order
dag = {
"shirt": ["tie", "belt"],
"tie": ["jacket"],
"belt": ["jacket"],
"jacket": [],
"pants": ["belt", "shoes"],
"shoes": [],
}
print(topo_sort(dag))
# ['shirt', 'pants', 'tie', 'belt', 'shoes', 'jacket']
Finds shortest paths from a source in a graph with non-negative weights.
Time \(O((V + E)\log V)\) • Space \(O(V)\)
import heapq
def dijkstra(graph, start):
dist = {node: float("inf") for node in graph}
dist[start] = 0
parent = {start: None}
heap = [(0, start)]
while heap:
d, node = heapq.heappop(heap)
if d > dist[node]:
continue # stale entry
for neighbour, weight in graph[node]:
new_dist = d + weight
if new_dist < dist[neighbour]:
dist[neighbour] = new_dist
parent[neighbour] = node
heapq.heappush(heap, (new_dist, neighbour))
return dist, parent
d, p = dijkstra(weighted, "A")
for node in sorted(d):
print(f"A → {node}: {d[node]}")
# A → A: 0
# A → B: 4
# A → C: 2
# A → D: 5 (via C)
Efficiently tracks connectivity. Used in Kruskal's MST algorithm, cycle detection, and clustering.
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path compression
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return False
if self.rank[ra] < self.rank[rb]:
ra, rb = rb, ra
self.parent[rb] = ra
if self.rank[ra] == self.rank[rb]:
self.rank[ra] += 1
return True
def connected(self, a, b):
return self.find(a) == self.find(b)
uf = UnionFind(6)
uf.union(0, 1); uf.union(1, 2); uf.union(3, 4)
print(uf.connected(0, 2)) # True
print(uf.connected(0, 3)) # False
def kruskal(n, edges):
"""edges = list of (weight, u, v)."""
edges.sort()
uf = UnionFind(n)
mst = []
total = 0
for w, u, v in edges:
if uf.union(u, v):
mst.append((u, v, w))
total += w
if len(mst) == n - 1:
break
return mst, total
edges = [(1, 0, 1), (4, 0, 2), (2, 1, 2), (5, 1, 3),
(3, 2, 3), (7, 2, 4), (6, 3, 4)]
mst, total = kruskal(5, edges)
print("MST:", mst)
print("Total weight:", total) # 1 + 2 + 3 + 6 = 12
| Algorithm | Time | Space | Key idea |
|---|---|---|---|
| BFS | \(O(V+E)\) | \(O(V)\) | Queue, level order |
| DFS | \(O(V+E)\) | \(O(V)\) | Stack, backtracking |
| Topological sort | \(O(V+E)\) | \(O(V)\) | Kahn's or DFS finish times |
| Dijkstra | \(O((V+E)\log V)\) | \(O(V)\) | Greedy + min-heap |
| Bellman–Ford | \(O(VE)\) | \(O(V)\) | Handles negative weights |
| Floyd–Warshall | \(O(V^3)\) | \(O(V^2)\) | All-pairs shortest paths |
| Kruskal MST | \(O(E\log E)\) | \(O(V)\) | Union–Find + sorting |
| Prim MST | \(O((V+E)\log V)\) | \(O(V)\) | Greedy growth from a node |
90% of graph interview questions are BFS/DFS in disguise. State first whether the graph is weighted (Dijkstra) or unweighted (BFS), and whether a cycle is expected. Recognise "minimum steps" → BFS, "topological order" → Kahn's, "shortest path with weights" → Dijkstra.
def naive_search(text, pattern):
n, m = len(text), len(pattern)
matches = []
for i in range(n - m + 1):
if text[i:i + m] == pattern:
matches.append(i)
return matches
print(naive_search("ababcabcab", "abc")) # [2, 5]
Preprocesses the pattern to build a failure function (LPS array) so that after a mismatch, the search resumes without re-checking characters.
def build_lps(pattern):
"""Longest proper prefix which is also a suffix, for each position."""
lps = [0] * len(pattern)
length = 0
i = 1
while i < len(pattern):
if pattern[i] == pattern[length]:
length += 1
lps[i] = length
i += 1
elif length != 0:
length = lps[length - 1]
else:
lps[i] = 0
i += 1
return lps
def kmp_search(text, pattern):
if not pattern:
return []
lps = build_lps(pattern)
matches = []
i = j = 0
while i < len(text):
if text[i] == pattern[j]:
i += 1; j += 1
if j == len(pattern):
matches.append(i - j)
j = lps[j - 1]
elif j != 0:
j = lps[j - 1]
else:
i += 1
return matches
print(build_lps("ABABCABAB")) # [0,0,1,2,0,1,2,3,4]
print(kmp_search("ABABDABACDABABCABAB", "ABABCABAB")) # [10]
def rabin_karp(text, pattern, base=256, mod=10**9 + 7):
n, m = len(text), len(pattern)
if m > n:
return []
# Pre-compute base^(m-1) mod
high = pow(base, m - 1, mod)
ph = 0
th = 0
for i in range(m):
ph = (ph * base + ord(pattern[i])) % mod
th = (th * base + ord(text[i])) % mod
matches = []
for i in range(n - m + 1):
if ph == th and text[i:i + m] == pattern:
matches.append(i)
if i < n - m:
th = (th - ord(text[i]) * high) * base + ord(text[i + m])
th %= mod
return matches
print(rabin_karp("the quick brown fox", "quick")) # [4]
A tree where each node represents a prefix. Enables \(O(m)\) insert and search (where \(m\) is word length).
class TrieNode:
__slots__ = ("children", "is_end")
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
node = node.children.setdefault(ch, TrieNode())
node.is_end = True
def search(self, word):
node = self._find(word)
return node is not None and node.is_end
def starts_with(self, prefix):
return self._find(prefix) is not None
def _find(self, s):
node = self.root
for ch in s:
if ch not in node.children:
return None
node = node.children[ch]
return node
def autocomplete(self, prefix):
node = self._find(prefix)
if node is None:
return []
results = []
self._collect(node, prefix, results)
return results
def _collect(self, node, prefix, out):
if node.is_end:
out.append(prefix)
for ch, child in node.children.items():
self._collect(child, prefix + ch, out)
t = Trie()
for w in ["cat", "car", "card", "care", "dog", "do"]:
t.insert(w)
print(t.search("cat")) # True
print(t.search("ca")) # False
print(t.autocomplete("car")) # ['car', 'card', 'care']
print(t.autocomplete("do")) # ['do', 'dog']
Naive \(O(n^3)\), DP \(O(n^2)\), Manacher's \(O(n)\). Manacher is rarely required in interviews but good to know.
def longest_palindrome(s):
"""DP O(n^2) approach — clean and interview-friendly."""
n = len(s)
if n < 2:
return s
dp = [[False] * n for _ in range(n)]
start, max_len = 0, 1
for i in range(n):
dp[i][i] = True
for i in range(n - 1):
if s[i] == s[i + 1]:
dp[i][i + 1] = True
start, max_len = i, 2
for length in range(3, n + 1):
for i in range(n - length + 1):
j = i + length - 1
if s[i] == s[j] and dp[i + 1][j - 1]:
dp[i][j] = True
start, max_len = i, length
return s[start:start + max_len]
print(longest_palindrome("babad")) # 'bab' or 'aba'
print(longest_palindrome("cbbd")) # 'bb'
| Algorithm | Preprocess | Search | Use when |
|---|---|---|---|
| Naive | — | \(O(nm)\) | Tiny inputs, simplest code |
| KMP | \(O(m)\) | \(O(n)\) | Worst-case linear guarantee |
| Rabin–Karp | \(O(m)\) | \(O(n)\) avg | Multiple pattern search; rolling hash |
| Boyer–Moore | \(O(m + \sigma)\) | \(O(n/m)\) best | Long patterns; practical speed |
| Trie | \(O(\sum m_i)\) | \(O(m)\) | Prefix search / autocomplete |
| Aho–Corasick | \(O(\sum m_i)\) | \(O(n + \text{matches})\) | Many patterns simultaneously |
When data exceeds available RAM, pandas fails. Three approaches scale Python data processing:
| Tool | Scale up to | Model | Best for |
|---|---|---|---|
| Polars | ~100 GB | Single-machine, Rust, multi-threaded | Fast single-node analytics |
| Dask | ~TB on a cluster | Parallel pandas, lazy | Familiar pandas API at scale |
| PySpark | PB on a cluster | Distributed JVM + Python API | Enterprise big data |
| DuckDB | ~100 GB | In-process OLAP SQL engine | SQL-style analytics on local files |
pip install polars
import polars as pl
# Lazy API — the key to efficiency
lf = (
pl.scan_csv("sales.csv") # lazy, doesn't load yet
.filter(pl.col("amount") > 0)
.group_by("region")
.agg([
pl.col("amount").sum().alias("total"),
pl.col("amount").mean().alias("avg"),
pl.len().alias("orders"),
])
.sort("total", descending=True)
)
df = lf.collect() # executes the plan efficiently
print(df)
| Feature | pandas | Polars |
|---|---|---|
| Backend | NumPy (single-thread) | Rust (multi-thread) |
| Lazy evaluation | No | Yes (.lazy()) |
| Query optimisation | No | Yes (predicate pushdown, projection) |
| Memory efficiency | Moderate | High (Arrow) |
| Typical speedup | 1× | 5–30× |
pip install "dask[complete]"
import dask.dataframe as dd
# Dask reads CSV in chunks and builds a task graph
ddf = dd.read_csv("large_*.csv")
result = (
ddf[ddf["amount"] > 0]
.groupby("region")["amount"]
.agg(["sum", "mean", "count"])
.compute() # trigger execution
)
print(result)
You already have pandas code and it needs to scale beyond RAM. Dask mirrors the pandas API, so migration is often a one-line change (pd.read_csv → dd.read_csv).
pip install pyspark
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
spark = (SparkSession.builder
.appName("SalesAnalysis")
.getOrCreate())
df = spark.read.csv("s3://bucket/sales/*.csv", header=True, inferSchema=True)
result = (
df.filter(F.col("amount") > 0)
.groupBy("region")
.agg(
F.sum("amount").alias("total"),
F.avg("amount").alias("avg"),
F.count("*").alias("orders"),
)
.orderBy(F.desc("total"))
)
result.show()
spark.stop()
For simple cases, no framework is needed — process in chunks with a generator.
def read_in_chunks(filepath, chunk_size=100_000):
with open(filepath) as f:
chunk = []
for line in f:
chunk.append(line)
if len(chunk) == chunk_size:
yield chunk
chunk = []
if chunk:
yield chunk
def count_errors(filepath):
total = 0
for chunk in read_in_chunks(filepath):
total += sum(1 for line in chunk if "ERROR" in line)
return total
# Memory usage stays constant regardless of file size.
| Feature | pandas | Polars | Dask | PySpark |
|---|---|---|---|---|
| Single-machine | Yes | Yes | Yes | No (cluster) |
| Distributed | No | No | Yes | Yes |
| Lazy eval | No | Yes | Yes | Yes |
| API style | Imperative | Expression | pandas-like | SQL-like |
| Streaming | No | Yes | Yes | Yes |
| Learning curve | Low | Low | Low | Medium |
For scaling questions, state the trade-off clearly: Polars for speed on one machine, Dask for pandas code that outgrows RAM, PySpark when data lives in a Hadoop/S3 cluster. Mention lazy evaluation as the key optimisation mechanism.
| Aspect | Classical ML (sklearn) | Deep Learning (PyTorch) |
|---|---|---|
| Features | Hand-engineered | Learned automatically |
| Data size | Small–medium | Large |
| Hardware | CPU | GPU/TPU |
| Interpretability | High | Low (black box) |
| Best for | Tabular data | Images, text, audio |
pip install torch torchvision
import torch
# Creation
a = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(a.shape, a.dtype) # torch.Size([2, 3]) torch.int64
# From NumPy
import numpy as np
b = torch.from_numpy(np.array([1.0, 2.0, 3.0]))
print(b.dtype) # torch.float32
# Zeros, ones, random
print(torch.zeros(2, 3))
print(torch.ones(2, 3))
print(torch.randn(2, 3)) # standard normal
# Operations (similar to NumPy)
x = torch.tensor([1.0, 2.0, 3.0])
y = torch.tensor([4.0, 5.0, 6.0])
print(x + y) # tensor([5., 7., 9.])
print(x * y) # element-wise
print(torch.dot(x, y)) # tensor(32.)
# GPU
device = "cuda" if torch.cuda.is_available() else "cpu"
x = x.to(device)
import torch
x = torch.tensor(3.0, requires_grad=True)
y = x ** 2 + 2 * x + 1
y.backward() # computes dy/dx
print(x.grad) # tensor(8.) → 2*3 + 2 = 8
If \(y = f(g(x))\), then \(\frac{dy}{dx} = f'(g(x)) \cdot g'(x)\). PyTorch records every operation and applies it in reverse (reverse-mode autodiff).
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
# 1. Model
class Net(nn.Module):
def __init__(self, in_features, hidden, out_classes):
super().__init__()
self.net = nn.Sequential(
nn.Linear(in_features, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, out_classes),
)
def forward(self, x):
return self.net(x)
# 2. Data
X = torch.randn(1000, 20)
y = torch.randint(0, 3, (1000,))
loader = DataLoader(TensorDataset(X, y), batch_size=32, shuffle=True)
# 3. Training setup
device = "cuda" if torch.cuda.is_available() else "cpu"
model = Net(20, 64, 3).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
# 4. Training loop
for epoch in range(5):
total_loss = 0.0
for xb, yb in loader:
xb, yb = xb.to(device), yb.to(device)
optimizer.zero_grad()
logits = model(xb)
loss = criterion(logits, yb)
loss.backward()
optimizer.step()
total_loss += loss.item()
print(f"Epoch {epoch + 1}: loss = {total_loss / len(loader):.4f}")
1. Zero gradients → 2. Forward pass → 3. Compute loss → 4. loss.backward() → 5. optimizer.step() → 6. Log metrics.
class ConvNet(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(64 * 8 * 8, 128),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(128, num_classes),
)
def forward(self, x):
return self.classifier(self.features(x))
# Save state dict (recommended)
torch.save(model.state_dict(), "model.pt")
# Load
model = Net(20, 64, 3)
model.load_state_dict(torch.load("model.pt", map_location="cpu"))
model.eval()
model.eval() for inference
It turns off dropout and batch-norm updates. Forgetting it is the #1 bug in PyTorch inference code. Use with torch.no_grad(): to skip gradient tracking and save memory.
model.eval()
with torch.no_grad():
sample = torch.randn(1, 20)
logits = model(sample)
predicted_class = logits.argmax(dim=1).item()
print("Predicted:", predicted_class)
| Layer | Purpose |
|---|---|
nn.Linear | Fully connected layer |
nn.Conv2d | 2-D convolution (images) |
nn.LSTM / nn.GRU | Recurrent layers (sequences) |
nn.MultiheadAttention | Transformer attention |
nn.ReLU, nn.GELU, nn.Sigmoid | Activation functions |
nn.Dropout | Regularisation |
nn.BatchNorm2d, nn.LayerNorm | Normalisation |
A Large Language Model (LLM) is a neural network trained on massive text corpora to predict the next token. Modern LLMs follow a transformer architecture and can answer questions, generate code, summarise, translate and reason.
| Model family | Provider | Typical use |
|---|---|---|
| GPT-4 / GPT-4o | OpenAI | General reasoning, coding, multimodal |
| Claude | Anthropic | Long-context, safe generation |
| Gemini | Multimodal, search-augmented | |
| Llama | Meta (open weights) | Self-hosted, fine-tuning |
| Mistral / Mixtral | Mistral AI | Efficient open models |
pip install openai
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a helpful Python tutor."},
{"role": "user", "content": "Explain list comprehensions briefly."},
],
temperature=0.7,
max_tokens=200,
)
print(response.choices[0].message.content)
| Role | Purpose |
|---|---|
system | Sets behaviour, tone, and constraints |
user | End-user input |
assistant | Previous model responses (for multi-turn chat) |
tool | Results from tool/function calls |
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Write a haiku about Python."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
print(delta, end="", flush=True)
print()
from pydantic import BaseModel
from openai import OpenAI
class Student(BaseModel):
name: str
roll: int
cgpa: float
client = OpenAI()
text = "Aarav Kumar, roll number 101, has a CGPA of 8.7."
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Extract the student information."},
{"role": "user", "content": text},
],
response_format=Student,
)
student = response.choices[0].message.parsed
print(student.name, student.roll, student.cgpa) # Aarav Kumar 101 8.7
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["c", "f"]},
},
"required": ["city"],
},
},
}
]
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Weather in Delhi?"}],
tools=tools,
)
tool_call = response.choices[0].message.tool_calls[0]
print(tool_call.function.name) # get_weather
print(tool_call.function.arguments) # {"city": "Delhi"}
# Call the actual function, then send the result back to the model.
import numpy as np
def embed(texts):
response = client.embeddings.create(
model="text-embedding-3-small",
input=texts,
)
return np.array([d.embedding for d in response.data])
docs = [
"Python is a high-level programming language.",
"Cats are common household pets.",
"Machine learning is a subset of AI.",
]
doc_vecs = embed(docs)
query = "What is Python?"
q_vec = embed([query])[0]
# Cosine similarity
similarities = doc_vecs @ q_vec / (
np.linalg.norm(doc_vecs, axis=1) * np.linalg.norm(q_vec)
)
best = int(np.argmax(similarities))
print("Best match:", docs[best])
Documents → Chunks → Embeddings → Vector store → Retrieve top-k on query → Prompt LLM with context → Answer
def rag_answer(question, knowledge_base, k=3):
# 1. Embed the question
q_vec = embed([question])[0]
# 2. Retrieve top-k chunks
doc_vecs = embed(knowledge_base)
sims = doc_vecs @ q_vec / (
np.linalg.norm(doc_vecs, axis=1) * np.linalg.norm(q_vec)
)
top_k_idx = np.argsort(-sims)[:k]
context = "\n\n".join(knowledge_base[i] for i in top_k_idx)
# 3. Prompt the LLM with context
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system",
"content": "Answer using ONLY the provided context."},
{"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {question}"},
],
)
return response.choices[0].message.content
| Technique | Description |
|---|---|
| System prompt | Sets role and constraints up front |
| Few-shot examples | Provide input/output pairs to steer behaviour |
| Chain-of-thought | "Think step by step" for reasoning tasks |
| Structured output | Pydantic / JSON schema constraints |
| Retrieval (RAG) | Inject factual context to reduce hallucination |
| Guardrails | Validate output against policy and schema |
tiktoken for estimation.| Aspect | REST | GraphQL | gRPC |
|---|---|---|---|
| Transport | HTTP/JSON | HTTP/JSON | HTTP/2 + Protobuf |
| Schema | Optional (OpenAPI) | Strongly typed SDL | Protobuf .proto |
| Over/under-fetching | Common | Solved (client asks for fields) | No (typed messages) |
| Caching | HTTP cache friendly | Complex | Manual |
| Streaming | Limited | Subscriptions | Bidirectional streams |
| Best for | Public APIs, CRUD | Complex client-driven queries | Internal microservices, low-latency |
pip install "strawberry-graphql[fastapi]" fastapi uvicorn
from typing import Optional
import strawberry
from strawberry.fastapi import GraphQLRouter
from fastapi import FastAPI
@strawberry.type
class Student:
id: int
name: str
cgpa: float
branch: str
DB = [
Student(id=1, name="Aarav", cgpa=8.7, branch="CSE"),
Student(id=2, name="Diya", cgpa=9.3, branch="ECE"),
]
@strawberry.type
class Query:
@strawberry.field
def student(self, id: int) -> Optional[Student]:
return next((s for s in DB if s.id == id), None)
@strawberry.field
def students(self, branch: Optional[str] = None) -> list[Student]:
if branch:
return [s for s in DB if s.branch == branch]
return DB
@strawberry.type
class Mutation:
@strawberry.mutation
def add_student(self, name: str, cgpa: float, branch: str) -> Student:
new = Student(id=len(DB) + 1, name=name, cgpa=cgpa, branch=branch)
DB.append(new)
return new
schema = strawberry.Schema(query=Query, mutation=Mutation)
graphql_app = GraphQLRouter(schema)
app = FastAPI()
app.include_router(graphql_app, prefix="/graphql")
# Run: uvicorn main:app --reload
# GraphiQL UI: http://localhost:8000/graphql
query {
students(branch: "CSE") {
id
name
cgpa
}
}
mutation {
addStudent(name: "Kabir", cgpa: 7.9, branch: "MEC") {
id
name
}
}
pip install grpcio grpcio-tools
// student.proto
syntax = "proto3";
package student;
service StudentService {
rpc GetStudent (StudentRequest) returns (StudentReply);
rpc ListStudents (Empty) returns (stream StudentReply);
}
message StudentRequest { int32 id = 1; }
message Empty {}
message StudentReply {
int32 id = 1;
string name = 2;
float cgpa = 3;
}
# Generate Python stubs
python -m grpc_tools.protoc -I. \
--python_out=. --grpc_python_out=. student.proto
# server.py
import grpc
from concurrent import futures
import student_pb2, student_pb2_grpc
class StudentServicer(student_pb2_grpc.StudentServiceServicer):
def GetStudent(self, request, context):
return student_pb2.StudentReply(id=request.id, name="Aarav", cgpa=8.7)
def ListStudents(self, request, context):
for name in ["Aarav", "Diya", "Kabir"]:
yield student_pb2.StudentReply(id=0, name=name, cgpa=8.0)
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
student_pb2_grpc.add_StudentServiceServicer_to_server(StudentServicer(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()
# client.py
import grpc
import student_pb2, student_pb2_grpc
with grpc.insecure_channel("localhost:50051") as channel:
stub = student_pb2_grpc.StudentServiceStub(channel)
reply = stub.GetStudent(student_pb2.StudentRequest(id=1))
print(reply.name, reply.cgpa) # Aarav 8.7
for s in stub.ListStudents(student_pb2.Empty()):
print("Streamed:", s.name)
| Scenario | Choose |
|---|---|
| Public API, browser clients, cache-heavy | REST |
| Mobile/SPA with varied data needs | GraphQL |
| Internal microservices, low latency | gRPC |
| Real-time bidirectional streams | gRPC or WebSockets |
| Rapid prototyping | REST + FastAPI |
| Multiple backend services aggregating data | GraphQL gateway |
Never claim one is "better". Say: REST for public/simple, GraphQL when clients need flexibility, gRPC for internal high-throughput typed services. This shows architectural maturity.
Traditional tests check specific inputs. But real bugs hide in edge cases you did not think of. Property-based testing generates hundreds of random inputs and checks an invariant that must always hold.
pip install hypothesis
from hypothesis import given, strategies as st
def sort_list(xs):
return sorted(xs)
@given(st.lists(st.integers()))
def test_sort_preserves_length(xs):
assert len(sort_list(xs)) == len(xs)
@given(st.lists(st.integers()))
def test_sort_is_ordered(xs):
result = sort_list(xs)
for i in range(len(result) - 1):
assert result[i] <= result[i + 1]
@given(st.lists(st.integers()))
def test_sort_preserves_multiset(xs):
from collections import Counter
assert Counter(sort_list(xs)) == Counter(xs)
@given(st.lists(st.integers()), st.lists(st.integers()))
def test_sort_idempotent(xs, ys):
combined = xs + ys
assert sort_list(combined) == sort_list(sort_list(combined))
Hypothesis automatically finds the smallest failing example (shrink) and reports it with a reproducible seed.
| Strategy | Generates |
|---|---|
st.integers(min_value, max_value) | Integers in a range |
st.floats(allow_nan=False) | Floating-point numbers |
st.text(min_size=1) | Unicode strings |
st.lists(elements, max_size=10) | Lists |
st.dictionaries(keys, values) | Dicts |
st.tuples(a, b, c) | Tuples of strategies |
st.one_of(s1, s2) | Pick one of the strategies |
st.sampled_from([...]) | Random choice from a list |
st.builds(MyClass, x=..., y=...) | Build objects |
from hypothesis import strategies as st
from hypothesis import given
@st.composite
def student(draw):
return {
"name": draw(st.text(min_size=1, max_size=20)),
"age": draw(st.integers(min_value=15, max_value=100)),
"cgpa": draw(st.floats(min_value=0, max_value=10)),
}
@given(student())
def test_student_has_valid_fields(s):
assert 1 <= len(s["name"]) <= 20
assert 15 <= s["age"] <= 100
assert 0 <= s["cgpa"] <= 10
Mutation testing modifies ("mutates") the source code — flipping conditions, swapping operators, deleting lines — and checks whether the test suite catches each change. If tests still pass, they are insufficient.
pip install mutmut
mutmut run --paths-to-mutate=src/
mutmut results
mutmut show 3
| Original | Mutated |
|---|---|
a < b | a <= b |
a + b | a - b |
return True | return False |
if x: | if not x: |
| Statement deleted | — |
pip install atheris
# Atheris is Google's coverage-guided fuzzer for Python.
# import atheris
# import sys
#
# def TestOneInput(data):
# fdp = atheris.FuzzedDataProvider(data)
# text = fdp.ConsumeUnicodeNoSurrogates(100)
# # Call code that should not crash on any input.
# try:
# import json
# json.loads(text)
# except json.JSONDecodeError:
# pass
#
# atheris.Setup(sys.argv, TestOneInput)
# atheris.Fuzz()
| Layer | Proportion | Example |
|---|---|---|
| Unit | 70% | test_sort() |
| Integration | 20% | test_create_student_and_read_back() |
| End-to-End | 10% | test_full_checkout_flow() |
| Property / Fuzz | Supplements all | Hypothesis, Atheris |
Name at least three properties to test: invariants (always true), idempotence (f(f(x)) = f(x)), roundtrip (decode(encode(x)) = x), commutativity, associativity. Show one Hypothesis example and one mutation testing command.
1. Clarify requirements (functional + non-functional) — 5 min
2. Estimate scale (QPS, storage, bandwidth) — 5 min
3. High-level design (boxes and arrows) — 10 min
4. Deep dive into 1–2 components — 15 min
5. Bottlenecks, trade-offs, failure modes — 10 min
| Operation | Latency (order of magnitude) |
|---|---|
| L1 cache read | 1 ns |
| Main memory read | 100 ns |
| SSD random read | 100 μs |
| Disk seek | 10 ms |
| Same-datacenter round-trip | 0.5 ms |
| Cross-continent round-trip | 150 ms |
| Component | Typical capacity |
|---|---|
| Single Python server (FastAPI) | ~1k–10k req/s |
| PostgreSQL (single node) | ~10k read QPS |
| Redis | ~100k ops/s |
| Kafka partition | ~10 MB/s |
| S3 object | ~5 TB max, 5 GB single PUT |
| Component | Role | Python tech |
|---|---|---|
| Load balancer | Distribute traffic | Nginx, HAProxy, ALB |
| API gateway | Auth, routing, rate limits | Kong, FastAPI gateway |
| Application server | Business logic | FastAPI + Uvicorn |
| Relational DB | ACID transactional storage | PostgreSQL, MySQL |
| NoSQL DB | Flexible schema, high write throughput | MongoDB, DynamoDB |
| Cache | Sub-ms reads, session storage | Redis, Memcached |
| Message queue | Decouple producers from consumers | Kafka, RabbitMQ, SQS |
| Search engine | Full-text search | Elasticsearch, Meilisearch |
| Object storage | Files, images, backups | S3, GCS, MinIO |
| CDN | Static content, edge caching | CloudFront, Cloudflare |
| Strategy | Behaviour | Trade-off |
|---|---|---|
| Cache-aside (lazy) | Read from cache; on miss, read DB and populate | Simple; stale on write |
| Write-through | Write cache and DB together | Consistent; slower writes |
| Write-behind | Write cache; flush to DB async | Fast writes; data-loss risk |
| Read-through | Cache fetches from DB on miss | Transparent to app |
import redis, json, hashlib
cache = redis.Redis(host="localhost", port=6379, decode_responses=True)
def get_student(db, student_id):
key = f"student:{student_id}"
cached = cache.get(key)
if cached:
return json.loads(cached) # cache hit
student = db.get(student_id) # cache miss
if student:
cache.setex(key, 300, json.dumps(student)) # 5-min TTL
return student
Cache invalidation is one of the two hard problems in CS. Strategies: TTL, write-through invalidation, versioned keys, or event-driven invalidation via a message queue.
| Pattern | Description |
|---|---|
| Indexing | B-tree indexes on frequently queried columns |
| Sharding | Split data across nodes by key (user_id, region) |
| Replication | Primary for writes, replicas for reads |
| Read replicas | Scale read throughput horizontally |
| Partitioning | Split large tables by date or range |
| Connection pooling | Reuse DB connections (SQLAlchemy pool, pgbouncer) |
| Materialised views | Precompute expensive joins |
# SQLAlchemy connection pool
from sqlalchemy import create_engine
engine = create_engine(
"postgresql://user:pass@host/db",
pool_size=20, # persistent connections
max_overflow=10, # extra connections under load
pool_pre_ping=True, # verify connection is alive before use
pool_recycle=1800, # recycle connections after 30 min
)
| Pattern | Purpose |
|---|---|
| Retry with backoff | Handle transient failures |
| Circuit breaker | Stop calling a failing dependency |
| Bulkhead | Isolate resource pools per dependency |
| Timeout | Never wait forever |
| Idempotency keys | Safe retries of write operations |
| Graceful degradation | Serve stale data / partial results |
pybreakerpip install pybreaker
import pybreaker
import requests
breaker = pybreaker.CircuitBreaker(fail_max=5, reset_timeout=30)
@breaker
def call_remote_api():
r = requests.get("https://api.example.com/data", timeout=5)
r.raise_for_status()
return r.json()
# After 5 failures, the circuit opens for 30 seconds and calls fail fast.
Requirements: shorten URL, redirect on GET, 100M URLs, 10k QPS reads.
Design:
short_code PK, long_url, created_at, owner) with a Redis cache in front.import string, hashlib
ALPHABET = string.digits + string.ascii_letters # 62 chars
def encode_base62(num: int) -> str:
if num == 0:
return ALPHABET[0]
out = []
while num > 0:
num, rem = divmod(num, 62)
out.append(ALPHABET[rem])
return "".join(reversed(out))
def shorten(long_url: str) -> str:
digest = hashlib.sha256(long_url.encode()).hexdigest()
numeric = int(digest[:16], 16) # 64-bit slice
return encode_base62(numeric)[:7]
print(shorten("https://example.com/very/long/path"))
# e.g., 'jK9aBc2'
CQRS separates writes (commands that change state) from reads (queries that return data). Each side can be optimised independently.
| Side | Model | Typical storage |
|---|---|---|
| Command | Write-optimised, normalised | PostgreSQL / event log |
| Query | Read-optimised, denormalised | Elasticsearch / Redis / materialised views |
class CommandHandler:
"""Handles writes: validates and persists."""
def __init__(self, db, event_bus):
self.db = db
self.event_bus = event_bus
def create_task(self, task_id, title, owner):
self.db.execute(
"INSERT INTO tasks (id, title, owner) VALUES (?, ?, ?)",
(task_id, title, owner))
self.event_bus.publish("task.created",
{"id": task_id, "title": title, "owner": owner})
class QueryHandler:
"""Handles reads: uses a denormalised projection."""
def __init__(self, read_db):
self.read_db = read_db
def list_tasks(self, owner):
return self.read_db.execute(
"SELECT id, title FROM task_view WHERE owner = ?", (owner,)).fetchall()
Instead of storing only the current state, event sourcing stores every state-changing event. Current state is derived by replaying events. Full audit log and time-travel debugging come for free.
from dataclasses import dataclass, field
from typing import List
from datetime import datetime
@dataclass
class Event:
type: str
data: dict
ts: datetime = field(default_factory=datetime.utcnow)
class BankAccount:
def __init__(self, account_id):
self.id = account_id
self.balance = 0
self.events: List[Event] = []
def _apply(self, event: Event):
if event.type == "deposited":
self.balance += event.data["amount"]
elif event.type == "withdrawn":
self.balance -= event.data["amount"]
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
ev = Event("deposited", {"amount": amount})
self.events.append(ev)
self._apply(ev)
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
ev = Event("withdrawn", {"amount": amount})
self.events.append(ev)
self._apply(ev)
@classmethod
def replay(cls, account_id, events):
acc = cls(account_id)
for ev in events:
acc._apply(ev)
acc.events = events
return acc
acc = BankAccount("A1")
acc.deposit(1000)
acc.withdraw(300)
print(acc.balance) # 700
# Rebuild the same state from the event log
rebuilt = BankAccount.replay("A1", acc.events)
print(rebuilt.balance) # 700
Long-running business processes that span multiple services must handle partial failure. A saga is a sequence of local transactions, each with a compensating action for rollback.
| Style | Coordination | Trade-off |
|---|---|---|
| Choreography | Services react to each other's events | Simple, but hard to trace |
| Orchestration | A central coordinator drives steps | Easier to reason about, single point of failure |
class OrderSaga:
def __init__(self, payment, inventory, shipping):
self.payment = payment
self.inventory = inventory
self.shipping = shipping
self.compensations = []
def run(self, order):
try:
self.payment.charge(order)
self.compensations.append(lambda: self.payment.refund(order))
self.inventory.reserve(order)
self.compensations.append(lambda: self.inventory.release(order))
self.shipping.schedule(order)
return "success"
except Exception as e:
print(f"Saga failed: {e}. Rolling back...")
for undo in reversed(self.compensations):
try:
undo()
except Exception as undo_err:
print(f"Compensation failed: {undo_err}")
return "rolled_back"
-- Write the domain change and the event in ONE transaction.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
INSERT INTO outbox (event_type, payload, status)
VALUES ('account.debited', '{"account_id": 1, "amount": 100}', 'pending');
COMMIT;
-- A separate worker publishes outbox rows to Kafka/RabbitMQ and marks them 'sent'.
Problem solved: without the outbox, a crash between DB write and message publish causes an event to be lost.
| Need | Pattern |
|---|---|
| Audit log, replay, time travel | Event sourcing |
| Independent read/write scaling | CQRS |
| Cross-service business transaction | Saga |
| Reliable event publishing from a DB transaction | Outbox |
| Reacting to state changes in another service | Event-driven architecture |
| Idempotent processing | Idempotency keys + dedup store |
These patterns add significant complexity. Use them only when the problem demands it (high audit requirements, multi-service transactions). For a small CRUD API, plain REST + a relational DB is the right answer.
| Model | You manage | Example |
|---|---|---|
| Bare metal | Everything | On-prem server |
| IaaS | OS and above | AWS EC2, GCP Compute Engine |
| PaaS | Application only | AWS Elastic Beanstalk, Heroku, App Engine |
| CaaS (containers) | Container image | ECS, EKS, Cloud Run, AKS |
| FaaS (serverless) | Function code only | AWS Lambda, Azure Functions, Cloud Functions |
# handler.py
import json
def lambda_handler(event, context):
"""Entry point for AWS Lambda."""
name = event.get("queryStringParameters", {}).get("name", "World")
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"message": f"Hello, {name}!"}),
}
# Deployment with the AWS SAM CLI
# 1. Install SAM CLI, then initialise the project:
sam init --runtime python3.12
# 2. Build and deploy
sam build
sam deploy --guided
# main.tf
provider "aws" { region = "ap-south-1" }
resource "aws_lambda_function" "hello" {
function_name = "hello-python"
role = aws_iam_role.lambda_role.arn
handler = "handler.lambda_handler"
runtime = "python3.12"
filename = "package.zip"
timeout = 30
memory_size = 256
}
resource "aws_iam_role" "lambda_role" {
name = "lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
terraform init
terraform plan
terraform apply
# serverless.yml
service: task-api
provider:
name: aws
runtime: python3.12
region: ap-south-1
environment:
DATABASE_URL: ${env:DATABASE_URL}
functions:
api:
handler: handler.lambda_handler
events:
- httpApi:
path: /{proxy+}
method: ANY
serverless deploy
serverless logs -f api --tail
| Cause of slow cold start | Mitigation |
|---|---|
| Large dependency tree (pandas, torch) | Move heavy imports inside handlers, or use Lambda layers |
| Large deployment package | Trim dependencies, use zip with tree-shaking |
| VPC-attached Lambdas | Use VPC endpoints or move to non-VPC when possible |
| Under-provisioned memory | Increase memory (also increases CPU) |
| JVM / runtime init | Use provisioned concurrency for latency-critical paths |
# Lazy imports to reduce cold-start time
import json
def lambda_handler(event, context):
# Only import heavy libraries when actually needed
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3]})
return {"statusCode": 200, "body": json.dumps({"sum": int(df["a"].sum())})}
| Lever | Effect |
|---|---|
| Right-size memory | Lower per-invocation cost |
| Reduce cold starts | Provisioned concurrency but costlier |
| Use ARM (Graviton) | ~20% cheaper, often faster |
| Reserved / Savings Plans | Up to 70% savings on predictable workloads |
| Spot instances | Up to 90% savings for fault-tolerant work |
| Object storage lifecycle | Move old S3 data to Glacier |
# Structured logging for CloudWatch
import json, logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
logger.info(json.dumps({
"request_id": context.aws_request_id,
"path": event.get("path"),
"method": event.get("httpMethod"),
}))
return {"statusCode": 200, "body": "ok"}
AWS X-Ray, CloudWatch Logs Insights, and Datadog's Lambda extension provide tracing and metrics for serverless functions.
| Workload | Recommended |
|---|---|
| Steady web API | Containers (ECS, Cloud Run) |
| Event-driven, sporadic | Lambda |
| Long-running jobs (>15 min) | ECS/Fargate tasks or batch jobs |
| ML inference with GPU | GPU instances or SageMaker endpoints |
| Scheduled jobs | Lambda + EventBridge, or CronJob on K8s |
| Data pipelines | Step Functions, Airflow, Dagster |
For cloud questions, mention: 12-factor config, IaC (Terraform/SAM), cold-start mitigation, structured logging, and cost levers. Name at least one AWS service per compute model (EC2 = IaaS, ECS = CaaS, Lambda = FaaS).
| Round | Focus | Typical duration |
|---|---|---|
| Online assessment | 2–3 DSA problems | 60–90 min |
| Technical phone screen | 1 DSA problem + Python questions | 45 min |
| Coding (onsite 1) | DSA + problem-solving discussion | 60 min |
| Coding (onsite 2) | Design or advanced Python | 60 min |
| System design | Architecture of a real system | 60 min |
| Behavioural | STAR stories on collaboration | 45 min |
| Hiring manager | Fit, motivation, growth | 30 min |
Understand → Match (patterns) → Plan → Implement → Review → Evaluate (complexity)
| Problem clue | Pattern to reach for |
|---|---|
| Sorted array + search | Binary search |
| Contiguous subarray / substring | Sliding window |
| Two sorted arrays | Two pointers |
| Duplicates / missing number | Hash set / XOR |
| Top k | Heap / QuickSelect |
| All combinations / permutations | Backtracking |
| Optimal value with choices | Dynamic programming |
| Graph traversal | BFS / DFS |
| Shortest path (weighted) | Dijkstra / Bellman–Ford |
| Dependencies / ordering | Topological sort |
| Prefix search / autocomplete | Trie |
| Connectivity / cycles | Union–Find |
| Question | Key points |
|---|---|
| List vs tuple | Mutable vs immutable; tuple as dict key; memory/speed |
| Shallow vs deep copy | Inner objects shared vs independent; copy.deepcopy |
is vs == | Identity vs value equality; interning of small ints/strings |
| GIL | One bytecode thread at a time; I/O releases GIL; use processes for CPU |
| Decorators | Wrapper pattern; @wraps; three-level with args |
| Generators | Lazy evaluation; yield; memory savings |
| Closures | Inner function remembers enclosing scope; late-binding trap |
| Context managers | __enter__/__exit__; @contextmanager |
| MRO | C3 linearisation; super(); diamond inheritance |
| Metaclasses | Class-of-class; used in Django ORM, Pydantic |
__slots__ | Memory optimisation; removes per-instance dict |
| Exception hierarchy | BaseException → Exception → ValueError etc. |
| Mutable default arguments | Trap; use None and initialise inside |
| Python memory model | Refcount + generational GC; weakref |
| Async vs threads | Event loop vs OS threads; async for I/O, threads for mixed |
# BROKEN
def append_to(value, target=[]):
target.append(value)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] ← surprise!
# FIXED
def append_to(value, target=None):
if target is None:
target = []
target.append(value)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [2]
| Letter | Meaning |
|---|---|
| Situation | Context — where, when, who |
| Task | Your specific responsibility |
| Action | What you did and why |
| Result | Measurable outcome |
| Step | What to do |
|---|---|
| Restate | Say the problem in your own words |
| Clarify | Edge cases, input size, return type |
| Examples | Write at least two test cases by hand |
| Brute force | State a simple solution and its complexity |
| Optimise | Identify the pattern; propose a better algorithm |
| Code | Write clean code with good names; comment the tricky bits |
| Test | Trace through the examples; check edges |
| Complexity | State time and space explicitly |
| Week | Focus | Problems |
|---|---|---|
| 1 | Arrays, strings, hashing | 20 |
| 2 | Two pointers, sliding window | 15 |
| 3 | Stacks, queues, heaps | 15 |
| 4 | Recursion, backtracking | 15 |
| 5 | Trees, BST, tries | 20 |
| 6 | Graphs (BFS, DFS, Dijkstra) | 20 |
| 7 | Dynamic programming | 20 |
| 8 | Mock interviews + system design | 5 + 3 |
Even if you cannot finish the code, narrate your thinking. Interviewers hire for clarity, not perfection. State your plan, test your code aloud, and admit uncertainty explicitly — that is a strength, not a weakness.
A complete reference project tying together every concept in Units I–VI. This is what a "professional Python engineer" ships.
A production FastAPI service that (1) ingests documents, (2) stores embeddings, (3) answers user questions via an LLM using RAG, and (4) logs every request for observability. Deployed with Docker to AWS ECS.
Client → ALB → FastAPI (ECS) ─┬─ Postgres (metadata)
├─ Redis (cache)
├─ Qdrant / pgvector (embeddings)
└─ OpenAI API (LLM)
Background workers:
- Ingest worker (queue-driven) → chunk + embed + upsert
- Cleanup worker (scheduled) → prune old embeddings
Observability:
- Prometheus /metrics → Grafana
- Structured JSON logs → CloudWatch
- OpenTelemetry → Jaeger
doc-qa/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── src/
│ └── docqa/
│ ├── __init__.py
│ ├── main.py # FastAPI app
│ ├── config.py # Settings (pydantic-settings)
│ ├── database.py # SQLAlchemy engine + session
│ ├── models.py # ORM models
│ ├── schemas.py # Pydantic
│ ├── rag.py # Chunk + embed + retrieve + answer
│ ├── cache.py # Redis wrapper
│ ├── metrics.py # Prometheus instrumentation
│ ├── logging_config.py # JSON logs
│ ├── workers/
│ │ ├── ingest.py
│ │ └── cleanup.py
│ └── auth.py # JWT
└── tests/
├── conftest.py
├── test_api.py
├── test_rag.py
└── test_auth.py
# src/docqa/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql://user:pass@db:5432/docqa"
redis_url: str = "redis://cache:6379/0"
openai_api_key: str
jwt_secret: str
jwt_expire_min: int = 60
chunk_size: int = 500
chunk_overlap: int = 80
top_k: int = 4
log_level: str = "INFO"
class Config:
env_file = ".env"
settings = Settings()
# src/docqa/rag.py
import numpy as np
from openai import OpenAI
from .config import settings
client = OpenAI(api_key=settings.openai_api_key)
def chunk(text, size=500, overlap=80):
words = text.split()
step = size - overlap
return [
" ".join(words[i:i + size])
for i in range(0, len(words), step)
if words[i:i + size]
]
def embed(texts):
resp = client.embeddings.create(
model="text-embedding-3-small", input=texts)
return np.array([d.embedding for d in resp.data])
def answer(question, documents):
q_vec = embed([question])[0]
d_vec = embed(documents)
sims = d_vec @ q_vec / (
np.linalg.norm(d_vec, axis=1) * np.linalg.norm(q_vec))
top = np.argsort(-sims)[: settings.top_k]
context = "\n\n".join(documents[i] for i in top)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system",
"content": "Answer using ONLY the provided context."},
{"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {question}"},
],
)
return resp.choices[0].message.content, list(top)
# src/docqa/main.py
from fastapi import FastAPI, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from .config import settings
from .database import get_db, Base, engine
from .schemas import (
IngestRequest, IngestResponse, QueryRequest, QueryResponse)
from .rag import chunk, embed, answer
from .metrics import REQUEST_COUNT, REQUEST_LATENCY
from .logging_config import logger, configure_logging
from .auth import current_user
Base.metadata.create_all(engine)
configure_logging()
app = FastAPI(title="Doc QA", version="1.0.0")
# In-memory doc store (in production: pgvector)
DOCS: dict[str, list[str]] = {}
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
REQUEST_COUNT.labels(request.method, request.url.path).inc()
with REQUEST_LATENCY.labels(request.url.path).time():
response = await call_next(request)
logger.info("http.request",
extra={"method": request.method, "path": request.url.path,
"status": response.status_code})
return response
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/ingest", response_model=IngestResponse)
def ingest(payload: IngestRequest,
user: str = Depends(current_user)):
chunks = chunk(payload.text, settings.chunk_size, settings.chunk_overlap)
DOCS[payload.doc_id] = chunks
embed(chunks) # persist embeddings in production
return {"doc_id": payload.doc_id, "chunks": len(chunks)}
@app.post("/query", response_model=QueryResponse)
def query(payload: QueryRequest,
user: str = Depends(current_user)):
chunks = DOCS.get(payload.doc_id)
if not chunks:
raise HTTPException(404, "Document not found")
text, indices = answer(payload.question, chunks)
return {"answer": text, "source_chunks": indices}
# tests/test_api.py
from fastapi.testclient import TestClient
from unittest.mock import patch
from src.docqa.main import app
client = TestClient(app)
def auth_header(token="valid"):
return {"Authorization": f"Bearer {token}"}
@patch("src.docqa.auth.current_user", return_value="test-user")
@patch("src.docqa.rag.embed")
def test_ingest(mock_embed, mock_user):
mock_embed.return_value = [[0.0] * 8]
r = client.post("/ingest",
json={"doc_id": "d1", "text": "Python is great. " * 20},
headers=auth_header())
assert r.status_code == 200
assert r.json()["doc_id"] == "d1"
@patch("src.docqa.auth.current_user", return_value="test-user")
@patch("src.docqa.rag.answer", return_value=("Python is great.", [0]))
def test_query(mock_answer, mock_user):
r = client.post("/query",
json={"doc_id": "d1", "question": "What is Python?"},
headers=auth_header())
assert r.status_code in (200, 404)
version: "3.9"
services:
api:
build: .
ports: ["8000:8000"]
environment:
DATABASE_URL: postgresql://user:pass@db:5432/docqa
REDIS_URL: redis://cache:6379/0
OPENAI_API_KEY: ${OPENAI_API_KEY}
JWT_SECRET: ${JWT_SECRET}
depends_on: [db, cache]
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: docqa
volumes: [pgdata:/var/lib/postgresql/data]
cache:
image: redis:7-alpine
volumes:
pgdata:
| Area | Item |
|---|---|
| Code | Type hints, docstrings, ruff + mypy clean |
| Config | Environment-based; secrets in a vault |
| Data | Migrations (Alembic), backups, indexes |
| Cache | Redis for hot queries; TTLs set |
| LLM | Prompt-injection guards; token limits; retries |
| Testing | Unit + integration + property tests, ≥80% coverage |
| Observability | Metrics, logs, traces, alerts |
| Security | JWT, rate limits, input validation, dependency audit |
| Deploy | Docker image, CI/CD pipeline, healthchecks |
| Resilience | Retries, timeouts, circuit breakers |
| Cost | Right-sized instances, cache hit rate monitoring |
| Algorithm | Time | Use for |
|---|---|---|
| BFS | \(O(V+E)\) | Unweighted shortest path, level order |
| DFS | \(O(V+E)\) | Cycle detection, topological sort, components |
| Kahn's topo sort | \(O(V+E)\) | Task ordering, build systems |
| Dijkstra | \(O((V+E)\log V)\) | Weighted shortest path (non-negative) |
| Bellman–Ford | \(O(VE)\) | Negative weights, arbitrage detection |
| Floyd–Warshall | \(O(V^{3})\) | All-pairs shortest path |
| Kruskal MST | \(O(E\log E)\) | Minimum spanning tree |
| Union–Find | ~\(O(1)\) amortised | Connectivity queries |
| Algorithm | Complexity | Key idea |
|---|---|---|
| KMP | \(O(n+m)\) | Failure function (LPS) |
| Rabin–Karp | \(O(n)\) avg | Rolling hash |
| Trie | \(O(m)\) per op | Prefix tree |
| Manacher | \(O(n)\) | Longest palindromic substring |
| Aho–Corasick | \(O(n + \text{matches})\) | Multi-pattern matching |
| Data size | Recommended |
|---|---|
| Up to RAM (≤ 10 GB) | pandas / Polars |
| Beyond RAM on one machine | Polars streaming, DuckDB, Dask |
| Cluster-scale (TB–PB) | PySpark |
| Simple large files | Chunked generator processing |
| Concept | PyTorch API |
|---|---|
| Tensor creation | torch.tensor, torch.randn, torch.zeros |
| Autograd | requires_grad=True, loss.backward() |
| Model | class Net(nn.Module) with forward |
| Training loop | zero_grad → forward → loss → backward → step |
| Inference | model.eval() + torch.no_grad() |
| Save / load | torch.save(model.state_dict(), p) |
| Device | .to("cuda") |
| Feature | REST | GraphQL | gRPC |
|---|---|---|---|
| Transport | HTTP/JSON | HTTP/JSON | HTTP/2 + Protobuf |
| Schema | Optional | SDL | .proto |
| Streaming | Limited | Subscriptions | Bidirectional |
| Best for | Public APIs | Client-driven queries | Microservices |
| Need | Pattern |
|---|---|
| Scale reads | Cache (Redis) + read replicas |
| Scale writes | Sharding, partitioning |
| Decouple services | Message queue (Kafka, SQS) |
| Handle failure | Retries with backoff, circuit breaker |
| Audit history | Event sourcing |
| Cross-service transactions | Saga + outbox |
| Independent reads/writes | CQRS |
| Type | Tool |
|---|---|
| Unit | pytest |
| Property-based | hypothesis |
| Mutation | mutmut |
| Fuzz | atheris |
| Load | locust, k6 |
| Coverage | pytest-cov |
| Clue | Pattern |
|---|---|
| Sorted + search | Binary search |
| Contiguous window | Sliding window |
| Two sorted arrays | Two pointers |
| Duplicates | Hash set |
| Top k | Heap |
| Combinations | Backtracking |
| Optimal with choices | DP |
| Graph traversal | BFS / DFS |
| Dependencies | Topological sort |
| Connectivity | Union–Find |
from collections import deque
def shortest_path(graph, start, target):
if start == target:
return [start]
parent = {start: None}
q = deque([start])
while q:
node = q.popleft()
for neighbour in graph[node]:
if neighbour not in parent:
parent[neighbour] = node
if neighbour == target:
path = []
while neighbour is not None:
path.append(neighbour)
neighbour = parent[neighbour]
return path[::-1]
q.append(neighbour)
return None
g = {"A": ["B", "C"], "B": ["D"], "C": ["D", "E"], "D": ["F"], "E": ["F"], "F": []}
print(shortest_path(g, "A", "F")) # ['A', 'B', 'D', 'F']
def has_cycle_dfs(graph):
WHITE, GRAY, BLACK = 0, 1, 2
color = {n: WHITE for n in graph}
def dfs(n):
color[n] = GRAY
for m in graph[n]:
if color[m] == GRAY:
return True
if color[m] == WHITE and dfs(m):
return True
color[n] = BLACK
return False
return any(color[n] == WHITE and dfs(n) for n in graph)
from collections import deque
def has_cycle_kahn(graph):
indeg = {n: 0 for n in graph}
for n in graph:
for m in graph[n]:
indeg[m] += 1
q = deque([n for n, d in indeg.items() if d == 0])
seen = 0
while q:
n = q.popleft(); seen += 1
for m in graph[n]:
indeg[m] -= 1
if indeg[m] == 0:
q.append(m)
return seen != len(graph)
dag = {"A": ["B"], "B": ["C"], "C": []}
cyc = {"A": ["B"], "B": ["C"], "C": ["A"]}
print(has_cycle_dfs(dag), has_cycle_kahn(dag)) # False False
print(has_cycle_dfs(cyc), has_cycle_kahn(cyc)) # True True
import heapq
def dijkstra_path(graph, start, target):
dist = {n: float("inf") for n in graph}
dist[start] = 0
parent = {start: None}
heap = [(0, start)]
while heap:
d, node = heapq.heappop(heap)
if node == target:
break
if d > dist[node]:
continue
for nb, w in graph[node]:
nd = d + w
if nd < dist[nb]:
dist[nb] = nd
parent[nb] = node
heapq.heappush(heap, (nd, nb))
if dist[target] == float("inf"):
return None, float("inf")
path = []
cur = target
while cur is not None:
path.append(cur)
cur = parent[cur]
return path[::-1], dist[target]
g = {
"A": [("B", 4), ("C", 2)],
"B": [("C", 5), ("D", 10)],
"C": [("D", 3)],
"D": [],
}
print(dijkstra_path(g, "A", "D")) # (['A', 'C', 'D'], 5)
class TrieNode:
__slots__ = ("children", "is_end")
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
node = node.children.setdefault(ch, TrieNode())
node.is_end = True
def search(self, word):
node = self._find(word)
return node is not None and node.is_end
def _find(self, prefix):
node = self.root
for ch in prefix:
if ch not in node.children:
return None
node = node.children[ch]
return node
def autocomplete(self, prefix):
node = self._find(prefix)
results = []
if node:
self._collect(node, prefix, results)
return results
def _collect(self, node, prefix, out):
if node.is_end:
out.append(prefix)
for ch, child in node.children.items():
self._collect(child, prefix + ch, out)
t = Trie()
for w in ["cat", "car", "card", "care", "dog"]:
t.insert(w)
print(t.search("car")) # True
print(t.autocomplete("car")) # ['car', 'card', 'care']
def build_lps(p):
lps = [0] * len(p)
length = 0; i = 1
while i < len(p):
if p[i] == p[length]:
length += 1; lps[i] = length; i += 1
elif length != 0:
length = lps[length - 1]
else:
lps[i] = 0; i += 1
return lps
def kmp(text, pattern):
lps = build_lps(pattern)
i = j = 0
while i < len(text):
if text[i] == pattern[j]:
i += 1; j += 1
if j == len(pattern):
return i - j
elif j != 0:
j = lps[j - 1]
else:
i += 1
return -1
print(build_lps("ABABCABAB"))
# [0, 0, 1, 2, 0, 1, 2, 3, 4]
import polars as pl
result = (
pl.scan_csv("sales.csv")
.filter(pl.col("amount") > 100)
.group_by("region")
.agg([
pl.col("amount").sum().alias("total"),
pl.col("amount").mean().alias("avg"),
])
.sort("total", descending=True)
.collect()
)
print(result)
| Tool | Verdict |
|---|---|
| pandas | ✗ — 500 GB won't fit in memory |
| Polars streaming | △ — possible on a large single machine with streaming engine |
| Dask | ✓ — matches pandas API, parallelises on a cluster, good if the team already knows pandas |
| PySpark | ✓✓ — the industry default for multi-TB production pipelines; SQL interface and mature tooling |
Recommendation: PySpark, deployed on a managed cluster (Databricks, EMR), with partitioning on the date column. Use Dask if the workload is lighter and the team is Python-first.
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
class Net(nn.Module):
def __init__(self, d_in, d_h, n_classes):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_in, d_h), nn.ReLU(),
nn.Linear(d_h, d_h), nn.ReLU(),
nn.Linear(d_h, n_classes))
def forward(self, x):
return self.net(x)
X = torch.randn(2000, 10)
y = torch.randint(0, 3, (2000,))
loader = DataLoader(TensorDataset(X, y), batch_size=32, shuffle=True)
model = Net(10, 64, 3)
opt = optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()
for epoch in range(5):
for xb, yb in loader:
opt.zero_grad() # 1. zero gradients
logits = model(xb) # 2. forward pass
loss = loss_fn(logits, yb) # 3. compute loss
loss.backward() # 4. backward pass (autograd)
opt.step() # 5. update parameters
print(f"Epoch {epoch + 1}: loss = {loss.item():.4f}") # 6. log
import os, time
from pydantic import BaseModel
from openai import OpenAI, APIError, RateLimitError
class Extracted(BaseModel):
name: str
roll: int
cgpa: float
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
def extract(text, retries=3):
for attempt in range(1, retries + 1):
try:
resp = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Extract student info."},
{"role": "user", "content": text},
],
response_format=Extracted,
)
return resp.choices[0].message.parsed
except RateLimitError:
time.sleep(2 ** attempt)
except APIError as e:
print(f"API error: {e}")
if attempt == retries:
raise
return None
| Paradigm | Scenario |
|---|---|
| REST | Public blog API consumed by mobile apps and browsers; benefits from HTTP caching and OpenAPI docs |
| GraphQL | Internal data-aggregation layer for a React SPA that needs different fields on different screens |
| gRPC | Internal microservices communication with strict contracts, low latency, and bidirectional streaming |
from hypothesis import given, strategies as st
def encode(s: str) -> bytes:
return s.encode("utf-8")
def decode(b: bytes) -> str:
return b.decode("utf-8")
@given(st.text())
def test_roundtrip(s):
assert decode(encode(s)) == s
@given(st.text())
def test_encoded_is_bytes(s):
assert isinstance(encode(s), bytes)
@given(st.text())
def test_length_grows_or_stays(s):
# UTF-8 never shrinks a string's byte length below its character count
assert len(encode(s)) >= len(s)
Functional requirements: create short URL, redirect, optional analytics.
Non-functional: 100M URLs total, 10k reads/s, 100 writes/s, sub-100 ms p99 reads.
Data model:
CREATE TABLE urls (
short_code VARCHAR(8) PRIMARY KEY,
long_url TEXT NOT NULL,
owner_id BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
clicks BIGINT DEFAULT 0
);
CREATE INDEX idx_urls_owner ON urls(owner_id);
ID generation: Snowflake-like ID (timestamp + machine + sequence) → Base62 encode → take first 7 characters.
Read path: check Redis; on hit, redirect; on miss, query Postgres, populate Redis with 24h TTL, redirect.
Write path: validate URL, generate code, insert with unique constraint, retry on collision.
Bottleneck: DB write throughput at scale — shard by hash of short code.
Trade-off: 301 (permanent) is cache-friendly but loses click data; 302 (temporary) tracks clicks but reduces CDN/browser caching.
from dataclasses import dataclass, field
@dataclass
class Event:
type: str
data: dict
class Account:
def __init__(self, account_id):
self.id = account_id
self.balance = 0
self.events: list[Event] = []
def _apply(self, e):
if e.type == "deposited":
self.balance += e.data["amount"]
elif e.type == "withdrawn":
self.balance -= e.data["amount"]
def deposit(self, amount):
e = Event("deposited", {"amount": amount})
self.events.append(e)
self._apply(e)
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("Insufficient funds")
e = Event("withdrawn", {"amount": amount})
self.events.append(e)
self._apply(e)
# --- Command side ---
acc = Account("A1")
acc.deposit(1000); acc.withdraw(250)
# --- Query side (projection) ---
def project(events):
balance = 0
for e in events:
balance += e.data["amount"] if e.type == "deposited" else -e.data["amount"]
return {"id": "A1", "balance": balance}
print(project(acc.events)) # {'id': 'A1', 'balance': 750}
CQRS solves: read and write workloads scale differently. Event sourcing solves: full audit trail and time travel, plus projection flexibility (multiple read models from the same events).
| Service | When to use | Cold start |
|---|---|---|
| EC2 | Long-running services needing full OS control; predictable, steady load | None (instances run continuously) |
| ECS | Containerised services with autoscaling; predictable load or bursty with Fargate | Minutes (container start) |
| Lambda | Event-driven, sporadic workloads; IAM-integrated triggers | Hundreds of ms (Python); worse with large deps or VPC |
def two_sum(nums, target):
seen = {}
for i, n in enumerate(nums):
complement = target - n
if complement in seen:
return [seen[complement], i]
seen[n] = i
return []
print(two_sum([2, 7, 11, 15], 9)) # [0, 1]
Pattern: hash map for \(O(1)\) complement lookup. Time \(O(n)\), space \(O(n)\). This is the canonical "trade space for time" example.
# BROKEN — the default list is created once and shared across calls
def add_item(item, target=[]):
target.append(item)
return target
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] ← surprise
print(add_item("c")) # ['a', 'b', 'c']
# FIXED — use a sentinel, then create a fresh list inside
def add_item(item, target=None):
if target is None:
target = []
target.append(item)
return target
print(add_item("a")) # ['a']
print(add_item("b")) # ['b']
Why: default argument values are evaluated once at function definition time and stored on the function object (add_item.__defaults__). All calls without an explicit argument share that same list. Using None as a sentinel defers list creation to call time.
| Code | Title | Author | Publisher |
|---|---|---|---|
| T-1 | Fundamentals of Python — First Programs | Kenneth A. Lambert | Cengage Learning |
| R-1 | Python Programming: Using Problem Solving Approach | Reema Thareja | Oxford University Press |
| R-2 | Introduction to Algorithms (CLRS, 4th ed.) | Cormen et al. | MIT Press |
| R-3 | Cracking the Coding Interview (6th ed.) | Gayle Laakmann McDowell | CareerCup |
| R-4 | Designing Data-Intensive Applications | Martin Kleppmann | O'Reilly |
| R-5 | Designing Machine Learning Systems | Chip Huyen | O'Reilly |
| R-6 | Deep Learning with PyTorch | Stevens, Antiga, Viehmann | Manning |
| R-7 | Building Microservices (2nd ed.) | Sam Newman | O'Reilly |
| R-8 | Fundamentals of Software Architecture | Richards & Ford | O'Reilly |
| Code | Resource | Purpose |
|---|---|---|
| RW-1 | docs.python.org/3/library/ | Standard library reference |
| RW-2 | docs.pola.rs | Polars documentation |
| RW-3 | docs.dask.org | Dask documentation |
| RW-4 | spark.apache.org/docs/latest/api/python/ | PySpark docs |
| RW-5 | pytorch.org/docs/stable/ | PyTorch documentation |
| RW-6 | platform.openai.com/docs | OpenAI API docs |
| RW-7 | strawberry.rocks | Strawberry GraphQL docs |
| RW-8 | grpc.io/docs/languages/python/ | gRPC Python docs |
| RW-9 | hypothesis.readthedocs.io | Hypothesis documentation |
| RW-10 | github.com/checkcheckzz/system-design-interview | System design primer |
| RW-11 | neetcode.io | DSA practice roadmap |
| RW-12 | roadmap.sh/python | Python learning roadmap |
model.eval() for inference.| Course Outcome | Covered in Sections | Key Deliverables |
|---|---|---|
| CO3 — Functions & algorithms | I, II, VII | Graph and string algorithms; property-based testing |
| CO4 — Data structures at scale | I, III | Graphs, tries, Polars, Dask, PySpark |
| CO5 — OOP & design patterns | IX, X, XII | CQRS, event sourcing, saga; cloud deployment |
| CO6 — Full-stack integration | IV–VI, VIII, XI, XII | PyTorch, LLM APIs, GraphQL/gRPC, system design, interviews |
| Week | Focus | Sections |
|---|---|---|
| 1 | Graph algorithms | I |
| 2 | String algorithms | II |
| 3 | Large-scale data | III |
| 4 | Deep learning | IV |
| 5 | LLM APIs & RAG | V |
| 6 | GraphQL & gRPC | VI |
| 7 | Advanced testing | VII |
| 8 | System design | VIII |
| 9 | Advanced patterns | IX |
| 10 | Cloud deployment | X |
| 11 | Interview practice | XI |
| 12 | Capstone | XII |
| Unit | Core theme | Where it lives in Unit VI |
|---|---|---|
| Unit I | Basics, data structures | Graphs, tries, DSA patterns |
| Unit II | Functions, recursion, OOP | Advanced patterns, event sourcing |
| Unit III | Decorators, generators, files | Testing, cloud streaming, LLM APIs |
| Unit IV | Concurrency, APIs, data, ML | PyTorch, Polars, GraphQL/gRPC |
| Unit V | Metaprogramming, web, production | System design, cloud, observability |
| Unit VI | Integration & extension | End-to-end capstone project |
Course weightage: ATT 5 + CA 50 + ETP 45. Programming Practice requires solving at least 50% of the assigned coding problems and 50% of the MCQs to be eligible for marks. For Unit VI, prioritise: (1) 60–80 DSA problems across patterns, (2) one full-system capstone, (3) a written design doc for a system you've built.
Advanced Topics & Professional Mastery
INT108 · L:T:P 3:0:2 · 4 Credits
“First, solve the problem. Then, write the code.” — John Johnson
You have now completed the full INT108 · Python Programming arc — from first programs to production systems.